Merge branch 'build' of https://github.com/krtirtho/spotube into build

This commit is contained in:
Kingkor Roy Tirtho 2022-03-18 12:20:42 +06:00
commit c1b6e61666
68 changed files with 3176 additions and 2271 deletions

View File

@ -8,7 +8,7 @@
<img alt="GitHub release" src="https://img.shields.io/github/v/release/KRTirtho/spotube?color=%2316ba58&style=flat-square"/> <img alt="GitHub release" src="https://img.shields.io/github/v/release/KRTirtho/spotube?color=%2316ba58&style=flat-square"/>
</a> </a>
<a href="LICENSE"> <a href="LICENSE">
<img alt="License" src="https://img.shields.io/aur/license/spotube?color=%2316ba58&style=flat-square"/> <img alt="License" src="https://img.shields.io/aur/license/spotube-bin?color=%2316ba58&style=flat-square"/>
</a> </a>
<a href="https://github.com/KRTirtho"> <a href="https://github.com/KRTirtho">
<img alt="Maintainer" src="https://img.shields.io/badge/Maintainer-KRTirtho-%2316ba58?style=flat-square"/> <img alt="Maintainer" src="https://img.shields.io/badge/Maintainer-KRTirtho-%2316ba58?style=flat-square"/>
@ -46,7 +46,7 @@ All the binaries are located in the [releases](https://github.com/krtirtho/spotu
## Windows ## 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 ### Chocolatey
@ -71,7 +71,7 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
<a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a> <a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a>
### Ubuntu/Debian/Linux Mint/Pop_!OS: ### 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 ```bash
$ sudo apt install Spotube-linux-x86_64.deb $ sudo apt install Spotube-linux-x86_64.deb
# or # or
@ -91,10 +91,13 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
### AppImage: ### 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 # Configuration
There are some configurations that needs to be done to start using this software 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] Add support for show Lyric of currently playing track
- [x] Track download - [x] Track download
- [ ] Support for playing/streaming podcasts/shows - [ ] Support for playing/streaming podcasts/shows
- [ ] Artist, User & Album pages - [x] Artist, User & Album pages
# Building from source # Building from source
@ -149,7 +152,6 @@ $ flutter run -d <window|macos|linux>
- Shows & Podcasts aren't supported as it'd require premium anyway - Shows & Podcasts aren't supported as it'd require premium anyway
- OS Media Controls - OS Media Controls
- Global Media Shortcuts/Keyboard Media Buttons
# License # License

View File

@ -1,34 +1,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="oss.krtirtho.spotube">
package="oss.krtirtho.spotube">
<application <uses-permission android:name="android.permission.INTERNET" />
android:label="spotube"
android:name="${applicationName}" <queries>
android:icon="@mipmap/ic_launcher"> <!-- If your app opens https URLs -->
<activity <intent>
android:name=".MainActivity" <action android:name="android.intent.action.VIEW" />
android:exported="true" <data android:scheme="https" />
android:launchMode="singleTop" </intent>
android:theme="@style/LaunchTheme" </queries>
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" <application android:label="spotube" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">
android:windowSoftInputMode="adjustResize"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data android:name="flutterEmbedding" android:value="2" />
android:name="flutterEmbedding"
android:value="2" />
</application> </application>
</manifest> </manifest>

View File

@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.3.50' ext.kotlin_version = '1.6.10'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()

BIN
assets/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
assets/warmer.mp3 Normal file

Binary file not shown.

View File

@ -1,12 +1,12 @@
pkgbase = spotube-bin pkgbase = spotube-bin
pkgdesc = A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed 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 pkgver = 1.2.0
pkgrel = 1 pkgrel = 2
url = https://github.com/KRTirtho/spotube/ url = https://github.com/KRTirtho/spotube/
arch = x86_64 arch = x86_64
license = BSD-4-Clause license = BSD-4-Clause
depends = libkeybinder3 depends = libkeybinder3
source = https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.tar.xz source = https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.tar.xz
md5sums = 0db87627ddf753bc7f09ebbb282184ee md5sums = f49d21ef00c7d43eb70e7e9b2a7103c1
pkgname = spotube-bin pkgname = spotube-bin

View File

@ -1,7 +1,7 @@
# Maintainer: Kingkor Roy Tirtho <krtirho@gmail.com> # Maintainer: Kingkor Roy Tirtho <krtirho@gmail.com>
pkgname=spotube-bin pkgname=spotube-bin
pkgver=1.2.0 pkgver=1.2.0
pkgrel=1 pkgrel=2
epoch= epoch=
pkgdesc="A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed" pkgdesc="A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed"
arch=(x86_64) arch=(x86_64)
@ -21,16 +21,20 @@ install=
changelog= changelog=
source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/Spotube-linux-x86_64.tar.xz") source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/Spotube-linux-x86_64.tar.xz")
noextract=() noextract=()
md5sums=(0db87627ddf753bc7f09ebbb282184ee) md5sums=(f49d21ef00c7d43eb70e7e9b2a7103c1)
validpgpkeys=() validpgpkeys=()
package(){ 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/applications"
install -dm755 "${pkgdir}/usr/share/appdata"
install -dm755 "${pkgdir}/usr/share/${pkgname}" install -dm755 "${pkgdir}/usr/share/${pkgname}"
install -dm755 "${pkgdir}/usr/bin" install -dm755 "${pkgdir}/usr/bin"
cp -ra ./ "${pkgdir}/usr/share/${pkgname}"
cp ./spotube.desktop "${pkgdir}/usr/share/applications" mv ./spotube.desktop "${pkgdir}/usr/share/applications"
cp ./spotube-logo.png "${pkgdir}/usr/share/icons/${pkgname}" mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/"
ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/${pkgname}" 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"
} }

View File

@ -7,7 +7,7 @@ in verifying that this package's contents are trustworthy.
Please go to releases page Please go to releases page
https://github.com/KRTirtho/spotube/releases 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 https://github.com/KRTirtho/spotube/releases/tag/v1.0.1
1. get hashes. Run: 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: 2. The checksums should match the following:
--- ---
Version Hashes for v1.1.0 Version Hashes for v1.2.0
Algorithm Hash Path Algorithm Hash Path
--------- ---- ---- --------- ---- ----
SHA256 144fb4170b424ae9ecee8941354244cb9744c0913fdc69f730a8b5e40e56753d tools\Spotube-windows-x86_64-setup.exe SHA256 02c032e1a2b8f60969b7a65c6a5e21df2bf5834cc8d8062cf56a2c8245a2a90e tools\Spotube-windows-x86_64-setup.exe

View File

@ -37,5 +37,11 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(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
end end

View File

@ -43,5 +43,12 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true /> <true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
</dict> </dict>
</plist> </plist>

View File

@ -1,40 +1,42 @@
import 'package:flutter/material.dart'; 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:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumView.dart'; import 'package:spotube/components/Album/AlbumView.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.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/artist-to-string.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/simple-track-to-track.dart'; import 'package:spotube/helpers/simple-track-to-track.dart';
import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class AlbumCard extends StatelessWidget { class AlbumCard extends HookConsumerWidget {
final Album album; final Album album;
const AlbumCard(this.album, {Key? key}) : super(key: key); const AlbumCard(this.album, {Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
bool isPlaylistPlaying = playback.currentPlaylist != null && bool isPlaylistPlaying = playback.currentPlaylist != null &&
playback.currentPlaylist!.id == album.id; playback.currentPlaylist!.id == album.id;
final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard( return PlaybuttonCard(
imageUrl: imageToUrlString(album.images), imageUrl: imageToUrlString(album.images),
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: playback.currentPlaylist?.id != null && isPlaying: playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == album.id, playback.currentPlaylist?.id == album.id,
title: album.name!, title: album.name!,
description: description:
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}", "Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute( GoRouter.of(context).push("/album/${album.id}", extra: album);
builder: (context) {
return AlbumView(album);
},
));
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
SpotifyApi spotify = context.read<SpotifyDI>().spotifyApi; SpotifyApi spotify = ref.read(spotifyProvider);
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all()) List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
.map((track) => simpleTrackToTrack(track, album)) .map((track) => simpleTrackToTrack(track, album))
@ -48,6 +50,7 @@ class AlbumCard extends StatelessWidget {
thumbnail: album.images!.first.url!, thumbnail: album.images!.first.url!,
); );
playback.setCurrentTrack = tracks.first; playback.setCurrentTrack = tracks.first;
await playback.startPlaying();
}, },
); );
} }

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.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/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class AlbumView extends StatelessWidget { class AlbumView extends ConsumerWidget {
final AlbumSimple album; final AlbumSimple album;
const AlbumView(this.album, {Key? key}) : super(key: key); const AlbumView(this.album, {Key? key}) : super(key: key);
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) { playPlaylist(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id; var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
@ -28,15 +29,17 @@ class AlbumView extends StatelessWidget {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id; var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi; SpotifyApi spotify = ref.watch(spotifyProvider);
return Scaffold( return SafeArea(
child: Scaffold(
body: FutureBuilder<Iterable<TrackSimple>>( body: FutureBuilder<Iterable<TrackSimple>>(
future: spotify.albums.getTracks(album.id!).all(), future: spotify.albums.getTracks(album.id!).all(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -93,6 +96,7 @@ class AlbumView extends StatelessWidget {
], ],
); );
}), }),
),
); );
} }
} }

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart' hide Page; 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:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class ArtistAlbumView extends StatefulWidget { class ArtistAlbumView extends ConsumerStatefulWidget {
final String artistId; final String artistId;
final String artistName; final String artistName;
const ArtistAlbumView( const ArtistAlbumView(
@ -16,10 +16,10 @@ class ArtistAlbumView extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
State<ArtistAlbumView> createState() => _ArtistAlbumViewState(); ConsumerState<ArtistAlbumView> createState() => _ArtistAlbumViewState();
} }
class _ArtistAlbumViewState extends State<ArtistAlbumView> { class _ArtistAlbumViewState extends ConsumerState<ArtistAlbumView> {
final PagingController<int, Album> _pagingController = final PagingController<int, Album> _pagingController =
PagingController<int, Album>(firstPageKey: 0); PagingController<int, Album>(firstPageKey: 0);
@ -39,10 +39,9 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
_fetchPage(int pageKey) async { _fetchPage(int pageKey) async {
try { try {
SpotifyDI data = context.read<SpotifyDI>(); SpotifyApi spotifyApi = ref.watch(spotifyProvider);
Page<Album> albums = await data.spotifyApi.artists Page<Album> albums =
.albums(widget.artistId) await spotifyApi.artists.albums(widget.artistId).getPage(8, pageKey);
.getPage(8, pageKey);
var items = albums.items!.toList(); var items = albums.items!.toList();
@ -60,7 +59,8 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return SafeArea(
child: Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()), appBar: const PageWindowTitleBar(leading: BackButton()),
body: Column( body: Column(
children: [ children: [
@ -87,6 +87,7 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
), ),
], ],
), ),
),
); );
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistProfile.dart';
class ArtistCard extends StatelessWidget { class ArtistCard extends StatelessWidget {
final Artist artist; final Artist artist;
@ -9,13 +9,14 @@ class ArtistCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return InkWell(
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute( GoRouter.of(context).push("/artist/${artist.id}");
builder: (context) {
return ArtistProfile(artist.id!);
},
));
}, },
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: Ink( child: Ink(
@ -38,11 +39,7 @@ class ArtistCard extends StatelessWidget {
CircleAvatar( CircleAvatar(
maxRadius: 80, maxRadius: 80,
minRadius: 20, minRadius: 20,
backgroundImage: CachedNetworkImageProvider((artist backgroundImage: backgroundImage,
.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"),
), ),
Text( Text(
artist.name!, artist.name!,

View File

@ -1,60 +1,83 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.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/Artist/ArtistCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart'; import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/readable-number.dart'; import 'package:spotube/helpers/readable-number.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class ArtistProfile extends StatefulWidget { class ArtistProfile extends HookConsumerWidget {
final String artistId; final String artistId;
const ArtistProfile(this.artistId, {Key? key}) : super(key: key); const ArtistProfile(this.artistId, {Key? key}) : super(key: key);
@override @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<ArtistProfile> { final avatarWidth = useBreakpointValue(
@override sm: MediaQuery.of(context).size.width * 0.50,
Widget build(BuildContext context) { md: MediaQuery.of(context).size.width * 0.40,
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi; lg: MediaQuery.of(context).size.width * 0.18,
return Scaffold( xl: MediaQuery.of(context).size.width * 0.18,
xxl: MediaQuery.of(context).size.width * 0.18,
);
final breakpoint = useBreakpoints();
return SafeArea(
child: Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
leading: BackButton(), leading: BackButton(),
), ),
body: FutureBuilder<Artist>( body: FutureBuilder<Artist>(
future: spotify.artists.get(widget.artistId), future: spotify.artists.get(artistId),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive()); return const Center(child: CircularProgressIndicator.adaptive());
} }
return SingleChildScrollView( return SingleChildScrollView(
controller: parentScrollController,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [ children: [
const SizedBox(width: 50), const SizedBox(width: 50),
CircleAvatar( CircleAvatar(
radius: MediaQuery.of(context).size.width * 0.18, radius: avatarWidth,
backgroundImage: CachedNetworkImageProvider( backgroundImage: CachedNetworkImageProvider(
imageToUrlString(snapshot.data!.images), imageToUrlString(snapshot.data!.images),
), ),
), ),
Flexible( Padding(
child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
@ -64,21 +87,24 @@ class _ArtistProfileState extends State<ArtistProfile> {
color: Colors.blue, color: Colors.blue,
borderRadius: BorderRadius.circular(50)), borderRadius: BorderRadius.circular(50)),
child: Text(snapshot.data!.type!.toUpperCase(), child: Text(snapshot.data!.type!.toUpperCase(),
style: Theme.of(context) style: chipTextVariant?.copyWith(
.textTheme color: Colors.white)),
.headline6
?.copyWith(color: Colors.white)),
), ),
Text( Text(
snapshot.data!.name!, snapshot.data!.name!,
style: Theme.of(context).textTheme.headline2, style: breakpoint.isSm
? textTheme.headline4
: textTheme.headline2,
), ),
Text( Text(
"${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers", "${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers",
style: Theme.of(context).textTheme.headline5, style: breakpoint.isSm
? textTheme.bodyText1
: textTheme.headline5,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// TODO: Implement check if user follows this artist // TODO: Implement check if user follows this artist
// LIMITATION: spotify-dart lib // LIMITATION: spotify-dart lib
@ -122,7 +148,6 @@ class _ArtistProfileState extends State<ArtistProfile> {
], ],
), ),
), ),
),
], ],
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
@ -134,10 +159,11 @@ class _ArtistProfileState extends State<ArtistProfile> {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive()); child: CircularProgressIndicator.adaptive());
} }
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying = var isPlaylistPlaying =
playback.currentPlaylist?.id == snapshot.data?.id; playback.currentPlaylist?.id == snapshot.data?.id;
playPlaylist(List<Track> tracks, {Track? currentTrack}) { playPlaylist(List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist( playback.setCurrentPlaylist = CurrentPlaylist(
@ -152,6 +178,7 @@ class _ArtistProfileState extends State<ArtistProfile> {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
return Column(children: [ return Column(children: [
@ -173,8 +200,8 @@ class _ArtistProfileState extends State<ArtistProfile> {
: Icons.play_arrow_rounded), : Icons.play_arrow_rounded),
color: Colors.white, color: Colors.white,
onPressed: trackSnapshot.hasData onPressed: trackSnapshot.hasData
? () => ? () => playPlaylist(
playPlaylist(trackSnapshot.data!.toList()) trackSnapshot.data!.toList())
: null, : null,
), ),
) )
@ -192,8 +219,7 @@ class _ArtistProfileState extends State<ArtistProfile> {
index: index:
(track.value.album?.images?.length ?? 1) - (track.value.album?.images?.length ?? 1) -
1); 1);
return TracksTableView.buildTrackTile( return TrackTile(
context,
playback, playback,
duration: duration, duration: duration,
track: track, track: track,
@ -220,12 +246,10 @@ class _ArtistProfileState extends State<ArtistProfile> {
TextButton( TextButton(
child: const Text("See All"), child: const Text("See All"),
onPressed: () { onPressed: () {
Navigator.of(context).push(MaterialPageRoute( GoRouter.of(context).push(
builder: (context) => ArtistAlbumView( "/artist-album/$artistId",
widget.artistId, extra: snapshot.data?.name ?? "KRTX",
snapshot.data?.name ?? "KRTX", );
),
));
}, },
) )
], ],
@ -241,15 +265,19 @@ class _ArtistProfileState extends State<ArtistProfile> {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive()); child: CircularProgressIndicator.adaptive());
} }
return Center( return Scrollbar(
child: Wrap( controller: scrollController,
spacing: 20, child: SingleChildScrollView(
runSpacing: 20, controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: snapshot.data children: snapshot.data
?.map((album) => AlbumCard(album)) ?.map((album) => AlbumCard(album))
.toList() ?? .toList() ??
[], [],
), ),
),
); );
}, },
), ),
@ -260,7 +288,7 @@ class _ArtistProfileState extends State<ArtistProfile> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
FutureBuilder<Iterable<Artist>>( FutureBuilder<Iterable<Artist>>(
future: spotify.artists.getRelatedArtists(widget.artistId), future: spotify.artists.getRelatedArtists(artistId),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center( return const Center(
@ -284,6 +312,7 @@ class _ArtistProfileState extends State<ArtistProfile> {
); );
}, },
), ),
),
); );
} }
} }

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart' hide Page; 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:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistCard.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'; import 'package:spotube/provider/SpotifyDI.dart';
class CategoryCard extends StatefulWidget { class CategoryCard extends HookWidget {
final Category category; final Category category;
final Iterable<PlaylistSimple>? playlists; final Iterable<PlaylistSimple>? playlists;
const CategoryCard( const CategoryCard(
@ -14,11 +16,6 @@ class CategoryCard extends StatefulWidget {
this.playlists, this.playlists,
}) : super(key: key); }) : super(key: key);
@override
_CategoryCardState createState() => _CategoryCardState();
}
class _CategoryCardState extends State<CategoryCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -26,59 +23,81 @@ class _CategoryCardState extends State<CategoryCard> {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
widget.category.name ?? "Unknown", category.name ?? "Unknown",
style: Theme.of(context).textTheme.headline5, 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<SpotifyDI>( HookConsumer(
builder: (context, data, child) { builder: (context, ref, child) {
return FutureBuilder<Iterable<PlaylistSimple>>( SpotifyApi spotifyApi = ref.watch(spotifyProvider);
future: widget.playlists == null final scrollController = useScrollController();
? (widget.category.id != "user-featured-playlists" final pagingController =
? data.spotifyApi.playlists usePagingController<int, PlaylistSimple>(firstPageKey: 0);
.getByCategoryId(widget.category.id!)
: data.spotifyApi.playlists.featured) final _error = useState(false);
.getPage(4, 0) final mounted = useIsMounted();
.then((value) => value.items ?? [])
: Future.value(widget.playlists), useEffect(() {
builder: (context, snapshot) { listener(pageKey) async {
if (snapshot.hasError) { try {
return const Center(child: Text("Error occurred")); if (playlists != null &&
playlists?.isNotEmpty == true &&
mounted()) {
return pagingController.appendLastPage(playlists!.toList());
} }
if (!snapshot.hasData) { final Page<PlaylistSimple> page = await (category.id !=
return const Center( "user-featured-playlists"
child: CircularProgressIndicator.adaptive(), ? 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( if (_error.value) _error.value = false;
spacing: 20, } catch (e, stack) {
runSpacing: 20, if (mounted()) {
children: snapshot.data! if (!_error.value) _error.value = true;
.map((playlist) => PlaylistCard(playlist)) pagingController.error = e;
.toList(), }
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<int, PlaylistSimple>(
shrinkWrap: true,
pagingController: pagingController,
scrollController: scrollController,
scrollDirection: Axis.horizontal,
builderDelegate: PagedChildBuilderDelegate<PlaylistSimple>(
itemBuilder: (context, playlist, index) {
return PlaylistCard(playlist);
},
),
),
),
); );
});
}, },
) )
], ],

View File

@ -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<String> 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<Home> {
final PagingController<int, Category> _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<Auth>();
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<SpotifyDI>();
Page<Category> 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<Auth>(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<SpotifyDI>(builder: (context, data, widget) {
return FutureBuilder<User>(
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<Category>(
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()
],
),
);
}
}

View File

@ -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<String> 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<int, Category>(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<Category> 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<Category>(
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,
),
],
),
),
);
}
}

View File

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

View File

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

View File

@ -1,18 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class UserArtists extends StatefulWidget { class UserArtists extends ConsumerStatefulWidget {
const UserArtists({Key? key}) : super(key: key); const UserArtists({Key? key}) : super(key: key);
@override @override
State<UserArtists> createState() => _UserArtistsState(); ConsumerState<UserArtists> createState() => _UserArtistsState();
} }
class _UserArtistsState extends State<UserArtists> { class _UserArtistsState extends ConsumerState<UserArtists> {
final PagingController<String, Artist> _pagingController = final PagingController<String, Artist> _pagingController =
PagingController(firstPageKey: ""); PagingController(firstPageKey: "");
@ -22,8 +22,8 @@ class _UserArtistsState extends State<UserArtists> {
WidgetsBinding.instance?.addPostFrameCallback((timestamp) { WidgetsBinding.instance?.addPostFrameCallback((timestamp) {
_pagingController.addPageRequestListener((pageKey) async { _pagingController.addPageRequestListener((pageKey) async {
try { try {
SpotifyDI data = context.read<SpotifyDI>(); SpotifyApi spotifyApi = ref.read(spotifyProvider);
CursorPage<Artist> artists = await data.spotifyApi.me CursorPage<Artist> artists = await spotifyApi.me
.following(FollowingType.artist) .following(FollowingType.artist)
.getPage(15, pageKey); .getPage(15, pageKey);
@ -51,10 +51,10 @@ class _UserArtistsState extends State<UserArtists> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SpotifyDI data = context.watch<SpotifyDI>(); SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder<CursorPage<Artist>>( return FutureBuilder<CursorPage<Artist>>(
future: data.spotifyApi.me.following(FollowingType.artist).first(), future: spotifyApi.me.following(FollowingType.artist).first(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive()); return const Center(child: CircularProgressIndicator.adaptive());

View File

@ -2,14 +2,8 @@ import 'package:flutter/material.dart' hide Image;
import 'package:spotube/components/Library/UserArtists.dart'; import 'package:spotube/components/Library/UserArtists.dart';
import 'package:spotube/components/Library/UserPlaylists.dart'; import 'package:spotube/components/Library/UserPlaylists.dart';
class UserLibrary extends StatefulWidget { class UserLibrary extends StatelessWidget {
const UserLibrary({Key? key}) : super(key: key); const UserLibrary({Key? key}) : super(key: key);
@override
_UserLibraryState createState() => _UserLibraryState();
}
class _UserLibraryState extends State<UserLibrary> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Expanded( return Expanded(

View File

@ -1,18 +1,18 @@
import 'package:flutter/material.dart' hide Image; 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:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class UserPlaylists extends StatelessWidget { class UserPlaylists extends ConsumerWidget {
const UserPlaylists({Key? key}) : super(key: key); const UserPlaylists({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
SpotifyDI data = context.watch<SpotifyDI>(); SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder<Iterable<PlaylistSimple>>( return FutureBuilder<Iterable<PlaylistSimple>>(
future: data.spotifyApi.playlists.me.all(), future: spotifyApi.playlists.me.all(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive()); return const Center(child: CircularProgressIndicator.adaptive());

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.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:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Shared/Hyperlink.dart'; import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
@ -8,36 +9,33 @@ import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
class Login extends StatefulWidget { class Login extends HookConsumerWidget {
const Login({Key? key}) : super(key: key); const Login({Key? key}) : super(key: key);
@override @override
_LoginState createState() => _LoginState(); Widget build(BuildContext context, ref) {
} var clientIdController = useTextEditingController();
var clientSecretController = useTextEditingController();
class _LoginState extends State<Login> { var accessTokenController = useTextEditingController();
String clientId = ""; var fieldError = useState(false);
String clientSecret = "";
String accessToken = "";
bool _fieldError = false;
Future handleLogin(Auth authState) async { Future handleLogin(Auth authState) async {
try { try {
if (clientId == "" || clientSecret == "") { if (clientIdController.value.text == "" ||
return setState(() { clientSecretController.value.text == "") {
_fieldError = true; fieldError.value = true;
});
} }
await oauthLogin(context, clientId: clientId, clientSecret: clientSecret); await oauthLogin(
ref.read(authProvider),
clientId: clientIdController.value.text,
clientSecret: clientSecretController.value.text,
);
} catch (e) { } catch (e) {
print("[Login.handleLogin] $e"); print("[Login.handleLogin] $e");
} }
} }
@override Auth authState = ref.watch(authProvider);
Widget build(BuildContext context) {
return Consumer<Auth>(
builder: (context, authState, child) {
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar(), appBar: const PageWindowTitleBar(),
body: SingleChildScrollView( body: SingleChildScrollView(
@ -65,15 +63,11 @@ class _LoginState extends State<Login> {
child: Column( child: Column(
children: [ children: [
TextField( TextField(
controller: clientIdController,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: "Spotify Client ID", hintText: "Spotify Client ID",
label: Text("ClientID"), label: Text("ClientID"),
), ),
onChanged: (value) {
setState(() {
clientId = value;
});
},
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
@ -81,11 +75,7 @@ class _LoginState extends State<Login> {
hintText: "Spotify Client Secret", hintText: "Spotify Client Secret",
label: Text("Client Secret"), label: Text("Client Secret"),
), ),
onChanged: (value) { controller: clientSecretController,
setState(() {
clientSecret = value;
});
},
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
@ -94,11 +84,7 @@ class _LoginState extends State<Login> {
decoration: const InputDecoration( decoration: const InputDecoration(
label: Text("Genius Access Token (optional)"), label: Text("Genius Access Token (optional)"),
), ),
onChanged: (value) { controller: accessTokenController,
setState(() {
accessToken = value;
});
},
), ),
const SizedBox( const SizedBox(
height: 10, height: 10,
@ -107,16 +93,15 @@ class _LoginState extends State<Login> {
onPressed: () async { onPressed: () async {
await handleLogin(authState); await handleLogin(authState);
UserPreferences preferences = UserPreferences preferences =
context.read<UserPreferences>(); ref.read(userPreferencesProvider);
SharedPreferences localStorage = SharedPreferences localStorage =
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
preferences.setGeniusAccessToken(accessToken); preferences.setGeniusAccessToken(
accessTokenController.value.text);
await localStorage.setString( await localStorage.setString(
LocalStorageKeys.geniusAccessToken, LocalStorageKeys.geniusAccessToken,
accessToken); accessTokenController.value.text);
setState(() { accessTokenController.text = "";
accessToken = "";
});
}, },
child: const Text("Submit"), child: const Text("Submit"),
) )
@ -128,7 +113,5 @@ class _LoginState extends State<Login> {
), ),
), ),
); );
},
);
} }
} }

View File

@ -1,54 +1,62 @@
import 'package:flutter/material.dart'; 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:spotify/spotify.dart';
import 'package:spotube/components/Settings.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/artist-to-string.dart';
import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/helpers/getLyrics.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
class Lyrics extends StatefulWidget { class Lyrics extends HookConsumerWidget {
const Lyrics({Key? key}) : super(key: key); const Lyrics({Key? key}) : super(key: key);
@override @override
State<Lyrics> createState() => _LyricsState(); Widget build(BuildContext context, ref) {
} Playback playback = ref.watch(playbackProvider);
UserPreferences userPreferences = ref.watch(userPreferencesProvider);
class _LyricsState extends State<Lyrics> { var lyrics = useState({});
Map<String, String> _lyrics = {};
@override
Widget build(BuildContext context) {
Playback playback = context.watch<Playback>();
UserPreferences userPreferences = context.watch<UserPreferences>();
bool hasToken = (userPreferences.geniusAccessToken != null || bool hasToken = (userPreferences.geniusAccessToken != null ||
(userPreferences.geniusAccessToken?.isNotEmpty ?? false)); (userPreferences.geniusAccessToken?.isNotEmpty ?? false));
var lyricsFuture = useMemoized(() {
if (playback.currentTrack != null && if (playback.currentTrack == null ||
hasToken && !hasToken ||
playback.currentTrack!.id != _lyrics["id"]) { (playback.currentTrack?.id != null &&
getLyrics( playback.currentTrack?.id == lyrics.value["id"])) {
return null;
}
return getLyrics(
playback.currentTrack!.name!, playback.currentTrack!.name!,
artistsToString<Artist>(playback.currentTrack!.artists ?? []), artistsToString<Artist>(playback.currentTrack!.artists ?? []),
apiKey: userPreferences.geniusAccessToken!, apiKey: userPreferences.geniusAccessToken!,
optimizeQuery: true, optimizeQuery: true,
).then((lyrics) { );
if (lyrics != null) { }, [playback.currentTrack]);
setState(() {
_lyrics = {"lyrics": lyrics, "id": playback.currentTrack!.id!}; var lyricsSnapshot = useFuture(lyricsFuture);
});
} useEffect(() {
}); if (lyricsSnapshot.hasData && lyricsSnapshot.data != null) {
lyrics.value = {
"lyrics": lyricsSnapshot.data,
"id": playback.currentTrack!.id!
};
} }
if (_lyrics["lyrics"] != null && playback.currentTrack == null) { if (lyrics.value["lyrics"] != null && playback.currentTrack == null) {
setState(() { lyrics.value = {};
_lyrics = {};
});
} }
}, [
lyricsSnapshot.data,
lyricsSnapshot.hasData,
lyrics.value,
playback.currentTrack,
]);
if (_lyrics["lyrics"] == null && playback.currentTrack != null) { if (lyrics.value["lyrics"] == null && playback.currentTrack != null) {
if (!hasToken) { if (!hasToken) {
return Expanded( return Expanded(
child: Column( child: Column(
@ -62,11 +70,7 @@ class _LyricsState extends State<Lyrics> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.of(context).push(MaterialPageRoute( GoRouter.of(context).push("/settings");
builder: (context) {
return const Settings();
},
));
}, },
child: const Text("Add Access Token")) child: const Text("Add Access Token"))
], ],
@ -99,9 +103,10 @@ class _LyricsState extends State<Lyrics> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Center( child: Center(
child: Text( child: Text(
_lyrics["lyrics"] == null && playback.currentTrack == null lyrics.value["lyrics"] == null &&
playback.currentTrack == null
? "No Track being played currently" ? "No Track being played currently"
: _lyrics["lyrics"]!, : lyrics.value["lyrics"]!,
style: Theme.of(context).textTheme.headline6, style: Theme.of(context).textTheme.headline6,
), ),
), ),

View File

@ -1,319 +1,117 @@
import 'dart:async'; 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: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/Shared/DownloadTrackButton.dart';
import 'package:spotube/components/Player/PlayerControls.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/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:spotube/provider/Playback.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:spotube/provider/SpotifyDI.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); const Player({Key? key}) : super(key: key);
@override @override
_PlayerState createState() => _PlayerState(); Widget build(BuildContext context, ref) {
} Playback playback = ref.watch(playbackProvider);
class _PlayerState extends State<Player> with WidgetsBindingObserver { final _volume = useState(0.0);
late AudioPlayer player;
bool _isPlaying = false;
bool _shuffled = false;
Duration? _duration;
String? _currentTrackId; final breakpoint = useBreakpoints();
double _volume = 0; final AudioPlayer player = playback.player;
late YoutubeExplode youtube; final Future<SharedPreferences> future =
useMemoized(SharedPreferences.getInstance);
final AsyncSnapshot<SharedPreferences?> localStorage =
useFuture(future, initialData: null);
@override useEffect(() {
void initState() { /// warm up the audio player before playing actual audio
try { /// It's for resolving unresolved issue related to just_audio's
super.initState(); /// [disposeAllPlayers] method which is throwing
player = AudioPlayer(); /// [UnimplementedException] in the [PlatformInterface]
youtube = YoutubeExplode(); /// implementation
player.setAsset("assets/warmer.mp3");
return null;
}, []);
WidgetsBinding.instance?.addObserver(this); useEffect(() {
WidgetsBinding.instance?.addPostFrameCallback(_init); if (localStorage.hasData) {
} catch (e, stack) { _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
print("[Player.initState()] $e"); player.volume;
print(stack);
}
} }
return null;
}, [localStorage.data]);
_init(Duration timeStamp) async { String albumArt = useMemoized(
try { () => imageToUrlString(
setState(() {
_volume = player.volume;
});
player.playingStream.listen((playing) async {
setState(() {
_isPlaying = playing;
});
});
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;
});
}
});
player.processingStateStream.listen((event) async {
try {
if (event == ProcessingState.completed && _currentTrackId != null) {
_movePlaylistPositionBy(1);
}
} catch (e, stack) {
print("[PrecessingStateStreamListener] $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<Playback>();
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;
});
}
}
}
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);
}
}
_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<Playback>(
builder: (context, playback, widget) {
if (playback.currentPlaylist != null &&
playback.currentTrack != null) {
_playTrack(playback.currentTrack!, playback);
}
String? albumArt = imageToUrlString(
playback.currentTrack?.album?.images, playback.currentTrack?.album?.images,
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1, index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
),
[playback.currentTrack?.album?.images],
); );
return Material( final entryRef = useRef<OverlayEntry?>(null);
disposeOverlay() {
try {
entryRef.value?.remove();
entryRef.value = null;
} catch (e, stack) {
if (e is! AssertionError) {
print("[Player.useEffect.cleanup] $e");
print(stack);
}
}
}
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]);
// 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();
}
return Container(
color: Theme.of(context).backgroundColor,
child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (albumArt != null) Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
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 // controls
Flexible( const Expanded(
flex: 3, flex: 3,
child: PlayerControls( 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 // add to saved tracks
Expanded( Expanded(
@ -326,13 +124,15 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
height: 20, height: 20,
constraints: const BoxConstraints(maxWidth: 200), constraints: const BoxConstraints(maxWidth: 200),
child: Slider.adaptive( child: Slider.adaptive(
value: _volume, value: _volume.value,
onChanged: (value) async { onChanged: (value) async {
try { try {
await player.setVolume(value).then((_) { await player.setVolume(value).then((_) {
setState(() { _volume.value = value;
_volume = value; localStorage.data?.setDouble(
}); LocalStorageKeys.volume,
value,
);
}); });
} catch (e, stack) { } catch (e, stack) {
print("[VolumeSlider.onChange()] $e"); print("[VolumeSlider.onChange()] $e");
@ -347,10 +147,11 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
DownloadTrackButton( DownloadTrackButton(
track: playback.currentTrack, track: playback.currentTrack,
), ),
Consumer<SpotifyDI>(builder: (context, data, widget) { Consumer(builder: (context, ref, widget) {
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder<bool>( return FutureBuilder<bool>(
future: playback.currentTrack?.id != null future: playback.currentTrack?.id != null
? data.spotifyApi.tracks.me ? spotifyApi.tracks.me
.containsOne(playback.currentTrack!.id!) .containsOne(playback.currentTrack!.id!)
: Future.value(false), : Future.value(false),
initialData: false, initialData: false,
@ -366,10 +167,8 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
onPressed: () { onPressed: () {
if (!isLiked && if (!isLiked &&
playback.currentTrack?.id != null) { playback.currentTrack?.id != null) {
data.spotifyApi.tracks.me spotifyApi.tracks.me
.saveOne( .saveOne(playback.currentTrack!.id!);
playback.currentTrack!.id!)
.then((value) => setState(() {}));
} }
}); });
}); });
@ -381,8 +180,6 @@ class _PlayerState extends State<Player> with WidgetsBindingObserver {
) )
], ],
), ),
);
},
), ),
); );
} }

View File

@ -1,120 +1,67 @@
import 'dart:async';
import 'package:flutter/material.dart'; 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/helpers/zero-pad-num-str.dart';
import 'package:spotube/models/GlobalKeyActions.dart'; import 'package:spotube/hooks/playback.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:provider/provider.dart';
class PlayerControls extends StatefulWidget { class PlayerControls extends HookConsumerWidget {
final Stream<Duration> positionStream; final Color? iconColor;
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;
const PlayerControls({ const PlayerControls({
required this.positionStream, this.iconColor,
required this.isPlaying,
required this.duration,
required this.shuffled,
this.onShuffle,
this.onStop,
this.onSeek,
this.onNext,
this.onPrevious,
this.onPlay,
this.onPause,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@override @override
_PlayerControlsState createState() => _PlayerControlsState(); Widget build(BuildContext context, ref) {
final Playback playback = ref.watch(playbackProvider);
final AudioPlayer player = playback.player;
final _shuffled = useState(false);
final _duration = useState<Duration?>(playback.duration);
useEffect(() {
listener(Duration? duration) {
_duration.value = duration;
} }
class _PlayerControlsState extends State<PlayerControls> { playback.addDurationChangeListener(listener);
StreamSubscription? _timePositionListener;
late List<GlobalKeyActions> _hotKeys = [];
@override return () => playback.removeDurationChangeListener(listener);
void dispose() async { }, []);
await _timePositionListener?.cancel();
Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
super.dispose();
}
_playOrPause(key) async { final onNext = useNextTrack(playback);
try {
widget.isPlaying ? widget.onPause?.call() : await widget.onPlay?.call();
} catch (e, stack) {
print("[PlayPauseShortcut] $e");
print(stack);
}
}
_configureHotKeys(UserPreferences preferences) async { final onPrevious = usePreviousTrack(playback);
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,
);
}),
);
});
}
@override final _playOrPause = useTogglePlayPause(playback);
Widget build(BuildContext context) {
UserPreferences preferences = context.watch<UserPreferences>(); final duration = _duration.value ?? Duration.zero;
_configureHotKeys(preferences);
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 700), constraints: const BoxConstraints(maxWidth: 700),
child: Column( child: Column(
children: [ children: [
StreamBuilder<Duration>( StreamBuilder<Duration>(
stream: widget.positionStream, stream: player.positionStream,
builder: (context, snapshot) { builder: (context, snapshot) {
var totalMinutes = final totalMinutes =
zeroPadNumStr(widget.duration.inMinutes.remainder(60)); zeroPadNumStr(duration.inMinutes.remainder(60));
var totalSeconds = final totalSeconds =
zeroPadNumStr(widget.duration.inSeconds.remainder(60)); zeroPadNumStr(duration.inSeconds.remainder(60));
var currentMinutes = snapshot.hasData final currentMinutes = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
: "00"; : "00";
var currentSeconds = snapshot.hasData final currentSeconds = snapshot.hasData
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
: "00"; : "00";
var sliderMax = widget.duration.inSeconds; final sliderMax = duration.inSeconds;
var sliderValue = snapshot.data?.inSeconds ?? 0; final sliderValue = snapshot.data?.inSeconds ?? 0;
return Row( return Column(
children: [ children: [
Expanded( Slider.adaptive(
child: Slider.adaptive(
// cannot divide by zero // cannot divide by zero
// there's an edge case for value being bigger // there's an edge case for value being bigger
// than total duration. Keeping it resolved // than total duration. Keeping it resolved
@ -123,13 +70,26 @@ class _PlayerControlsState extends State<PlayerControls> {
: sliderValue / sliderMax, : sliderValue / sliderMax,
onChanged: (value) {}, onChanged: (value) {},
onChangeEnd: (value) { onChangeEnd: (value) {
widget.onSeek?.call(value * sliderMax); player.seek(
Duration(
seconds: (value * sliderMax).toInt(),
),
);
}, },
activeColor: iconColor,
), ),
), Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text( Text(
"$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds", "$currentMinutes:$currentSeconds",
) ),
Text("$totalMinutes:$totalSeconds"),
],
),
),
], ],
); );
}), }),
@ -138,35 +98,67 @@ class _PlayerControlsState extends State<PlayerControls> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.shuffle_rounded), icon: const Icon(Icons.shuffle_rounded),
color: color: _shuffled.value
widget.shuffled ? Theme.of(context).primaryColor : null, ? Theme.of(context).primaryColor
: iconColor,
onPressed: () { onPressed: () {
widget.onShuffle?.call(); 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( IconButton(
icon: const Icon(Icons.skip_previous_rounded), icon: const Icon(Icons.skip_previous_rounded),
color: iconColor,
onPressed: () { onPressed: () {
widget.onPrevious?.call(); onPrevious();
}), }),
IconButton( IconButton(
icon: Icon( icon: Icon(
widget.isPlaying playback.isPlaying
? Icons.pause_rounded ? Icons.pause_rounded
: Icons.play_arrow_rounded, : Icons.play_arrow_rounded,
), ),
onPressed: () => _playOrPause(null), color: iconColor,
onPressed: _playOrPause,
), ),
IconButton( IconButton(
icon: const Icon(Icons.skip_next_rounded), icon: const Icon(Icons.skip_next_rounded),
onPressed: () => widget.onNext?.call()), onPressed: () => onNext(),
color: iconColor,
),
IconButton( IconButton(
icon: const Icon(Icons.stop_rounded), icon: const Icon(Icons.stop_rounded),
onPressed: () => widget.onStop?.call(), 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,
) )
], ],
), ),
); ],
));
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,45 +1,46 @@
import 'package:flutter/material.dart'; 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:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistView.dart'; import 'package:spotube/components/Playlist/PlaylistView.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.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/helpers/image-to-url-string.dart';
import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class PlaylistCard extends StatefulWidget { class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
const PlaylistCard(this.playlist, {Key? key}) : super(key: key); const PlaylistCard(this.playlist, {Key? key}) : super(key: key);
@override @override
_PlaylistCardState createState() => _PlaylistCardState(); Widget build(BuildContext context, ref) {
} Playback playback = ref.watch(playbackProvider);
class _PlaylistCardState extends State<PlaylistCard> {
@override
Widget build(BuildContext context) {
Playback playback = context.watch<Playback>();
bool isPlaylistPlaying = playback.currentPlaylist != null && 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( return PlaybuttonCard(
title: widget.playlist.name!, margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
imageUrl: widget.playlist.images![0].url!, title: playlist.name!,
imageUrl: playlist.images![0].url!,
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute( GoRouter.of(context).push(
builder: (context) { "/playlist/${playlist.id}",
return PlaylistView(widget.playlist); extra: playlist,
}, );
));
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
SpotifyDI data = context.read<SpotifyDI>(); SpotifyApi spotifyApi = ref.read(spotifyProvider);
List<Track> tracks = (widget.playlist.id != "user-liked-tracks" List<Track> tracks = (playlist.id != "user-liked-tracks"
? await data.spotifyApi.playlists ? await spotifyApi.playlists
.getTracksByPlaylistId(widget.playlist.id!) .getTracksByPlaylistId(playlist.id!)
.all() .all()
: await data.spotifyApi.tracks.me.saved : await spotifyApi.tracks.me.saved
.all() .all()
.then((tracks) => tracks.map((e) => e.track!))) .then((tracks) => tracks.map((e) => e.track!)))
.toList(); .toList();
@ -48,11 +49,12 @@ class _PlaylistCardState extends State<PlaylistCard> {
playback.setCurrentPlaylist = CurrentPlaylist( playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks, tracks: tracks,
id: widget.playlist.id!, id: playlist.id!,
name: widget.playlist.name!, name: playlist.name!,
thumbnail: imageToUrlString(widget.playlist.images), thumbnail: imageToUrlString(playlist.images),
); );
playback.setCurrentTrack = tracks.first; playback.setCurrentTrack = tracks.first;
await playback.startPlaying();
}, },
); );
} }

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class PlaylistGenreView extends StatefulWidget { class PlaylistGenreView extends ConsumerWidget {
final String genreId; final String genreId;
final String genreName; final String genreName;
final Iterable<PlaylistSimple>? playlists; final Iterable<PlaylistSimple>? playlists;
@ -15,13 +15,9 @@ class PlaylistGenreView extends StatefulWidget {
this.playlists, this.playlists,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@override
_PlaylistGenreViewState createState() => _PlaylistGenreViewState();
}
class _PlaylistGenreViewState extends State<PlaylistGenreView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
leading: BackButton(), leading: BackButton(),
@ -29,21 +25,23 @@ class _PlaylistGenreViewState extends State<PlaylistGenreView> {
body: Column( body: Column(
children: [ children: [
Text( Text(
widget.genreName, genreName,
style: Theme.of(context).textTheme.headline4, style: Theme.of(context).textTheme.headline4,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
Consumer<SpotifyDI>( Consumer(
builder: (context, data, child) => Expanded( builder: (context, ref, child) {
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: FutureBuilder<Iterable<PlaylistSimple>>( child: FutureBuilder<Iterable<PlaylistSimple>>(
future: widget.playlists == null future: playlists == null
? (widget.genreId != "user-featured-playlists" ? (genreId != "user-featured-playlists"
? data.spotifyApi.playlists ? spotifyApi.playlists
.getByCategoryId(widget.genreId) .getByCategoryId(genreId)
.all() .all()
: data.spotifyApi.playlists.featured.all()) : spotifyApi.playlists.featured.all())
: Future.value(widget.playlists), : Future.value(playlists),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) { if (snapshot.hasError) {
return const Center(child: Text("Error occurred")); return const Center(child: Text("Error occurred"));
@ -65,7 +63,8 @@ class _PlaylistGenreViewState extends State<PlaylistGenreView> {
); );
}), }),
), ),
), );
},
) )
], ],
), ),

View File

@ -1,30 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart'; import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class PlaylistView extends StatefulWidget { class PlaylistView extends ConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
const PlaylistView(this.playlist, {Key? key}) : super(key: key); const PlaylistView(this.playlist, {Key? key}) : super(key: key);
@override
_PlaylistViewState createState() => _PlaylistViewState();
}
class _PlaylistViewState extends State<PlaylistView> { playPlaylist(Playback playback, List<Track> tracks,
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) { {Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id != null && var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == widget.playlist.id; playback.currentPlaylist?.id == playlist.id;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist( playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks, tracks: tracks,
id: widget.playlist.id!, id: playlist.id!,
name: widget.playlist.name!, name: playlist.name!,
thumbnail: imageToUrlString(widget.playlist.images), thumbnail: imageToUrlString(playlist.images),
); );
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
@ -32,21 +29,21 @@ class _PlaylistViewState extends State<PlaylistView> {
currentTrack.id != playback.currentTrack?.id) { currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
var isPlaylistPlaying = playback.currentPlaylist?.id != null && var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == widget.playlist.id; playback.currentPlaylist?.id == playlist.id;
return Consumer<SpotifyDI>(builder: (_, data, __) { return SafeArea(
return Scaffold( child: Scaffold(
body: FutureBuilder<Iterable<Track>>( body: FutureBuilder<Iterable<Track>>(
future: widget.playlist.id != "user-liked-tracks" future: playlist.id != "user-liked-tracks"
? data.spotifyApi.playlists ? spotifyApi.playlists.getTracksByPlaylistId(playlist.id).all()
.getTracksByPlaylistId(widget.playlist.id) : spotifyApi.tracks.me.saved
.all()
: data.spotifyApi.tracks.me.saved
.all() .all()
.then((tracks) => tracks.map((e) => e.track!)), .then((tracks) => tracks.map((e) => e.track!)),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -78,7 +75,7 @@ class _PlaylistViewState extends State<PlaylistView> {
), ),
), ),
Center( Center(
child: Text(widget.playlist.name!, child: Text(playlist.name!,
style: Theme.of(context).textTheme.headline4), style: Theme.of(context).textTheme.headline4),
), ),
snapshot.hasError snapshot.hasError
@ -100,7 +97,7 @@ class _PlaylistViewState extends State<PlaylistView> {
], ],
); );
}), }),
),
); );
});
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart' hide Page; 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:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart';
@ -11,27 +12,14 @@ import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyDI.dart';
class Search extends StatefulWidget { class Search extends HookConsumerWidget {
const Search({Key? key}) : super(key: key); const Search({Key? key}) : super(key: key);
@override @override
State<Search> createState() => _SearchState(); Widget build(BuildContext context, ref) {
} SpotifyApi spotify = ref.watch(spotifyProvider);
var controller = useTextEditingController();
class _SearchState extends State<Search> { var searchTerm = useState("");
late TextEditingController _controller;
String searchTerm = "";
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
Widget build(BuildContext context) {
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
return Expanded( return Expanded(
child: Column( child: Column(
@ -43,11 +31,9 @@ class _SearchState extends State<Search> {
Expanded( Expanded(
child: TextField( child: TextField(
decoration: const InputDecoration(hintText: "Search..."), decoration: const InputDecoration(hintText: "Search..."),
controller: _controller, controller: controller,
onSubmitted: (value) { onSubmitted: (value) {
setState(() { searchTerm.value = controller.value.text;
searchTerm = _controller.value.text;
});
}, },
), ),
), ),
@ -60,27 +46,25 @@ class _SearchState extends State<Search> {
textColor: Colors.white, textColor: Colors.white,
child: const Icon(Icons.search_rounded), child: const Icon(Icons.search_rounded),
onPressed: () { onPressed: () {
setState(() { searchTerm.value = controller.value.text;
searchTerm = _controller.value.text;
});
}, },
), ),
], ],
), ),
), ),
FutureBuilder<List<Page>>( FutureBuilder<List<Page>>(
future: searchTerm.isNotEmpty future: searchTerm.value.isNotEmpty
? spotify.search.get(searchTerm).first(10) ? spotify.search.get(searchTerm.value).first(10)
: null, : null,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData && searchTerm.isNotEmpty) { if (!snapshot.hasData && searchTerm.value.isNotEmpty) {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive(), child: CircularProgressIndicator.adaptive(),
); );
} else if (!snapshot.hasData && searchTerm.isEmpty) { } else if (!snapshot.hasData && searchTerm.value.isEmpty) {
return Container(); return Container();
} }
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
List<AlbumSimple> albums = []; List<AlbumSimple> albums = [];
List<Artist> artists = []; List<Artist> artists = [];
List<Track> tracks = []; List<Track> tracks = [];
@ -115,8 +99,7 @@ class _SearchState extends State<Search> {
...tracks.asMap().entries.map((track) { ...tracks.asMap().entries.map((track) {
String duration = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TracksTableView.buildTrackTile( return TrackTile(
context,
playback, playback,
track: track, track: track,
duration: duration, duration: duration,
@ -142,6 +125,7 @@ class _SearchState extends State<Search> {
playback.currentTrack?.id) { playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack; playback.setCurrentTrack = currentTrack;
} }
await playback.startPlaying();
}, },
); );
}), }),

View File

@ -1,48 +1,34 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart'; import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
import 'package:spotube/components/Shared/Hyperlink.dart'; import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/main.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/UserPreferences.dart';
class Settings extends StatefulWidget { class Settings extends HookConsumerWidget {
const Settings({Key? key}) : super(key: key); const Settings({Key? key}) : super(key: key);
@override @override
_SettingsState createState() => _SettingsState(); Widget build(BuildContext context, ref) {
} UserPreferences preferences = ref.watch(userPreferencesProvider);
ThemeMode theme = ref.watch(themeProvider);
var geniusAccessToken = useState<String?>(null);
TextEditingController textEditingController = useTextEditingController();
class _SettingsState extends State<Settings> { textEditingController.addListener(() {
TextEditingController? _textEditingController; geniusAccessToken.value = textEditingController.value.text;
String? _geniusAccessToken;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
_textEditingController?.addListener(() {
setState(() {
_geniusAccessToken = _textEditingController?.value.text;
}); });
});
}
@override return SafeArea(
void dispose() { child: Scaffold(
_textEditingController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
UserPreferences preferences = context.watch<UserPreferences>();
return Scaffold(
appBar: PageWindowTitleBar( appBar: PageWindowTitleBar(
leading: const BackButton(), leading: const BackButton(),
center: Text( center: Text(
@ -66,7 +52,7 @@ class _SettingsState extends State<Settings> {
Expanded( Expanded(
flex: 1, flex: 1,
child: TextField( child: TextField(
controller: _textEditingController, controller: textEditingController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: preferences.geniusAccessToken, hintText: preferences.geniusAccessToken,
), ),
@ -75,19 +61,19 @@ class _SettingsState extends State<Settings> {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: ElevatedButton( child: ElevatedButton(
onPressed: _geniusAccessToken != null onPressed: geniusAccessToken.value != null
? () async { ? () async {
SharedPreferences localStorage = SharedPreferences localStorage =
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
preferences preferences.setGeniusAccessToken(
.setGeniusAccessToken(_geniusAccessToken); geniusAccessToken.value);
localStorage.setString( localStorage.setString(
LocalStorageKeys.geniusAccessToken, LocalStorageKeys.geniusAccessToken,
_geniusAccessToken!); geniusAccessToken.value ?? "");
setState(() {
_geniusAccessToken = null; geniusAccessToken.value = null;
});
_textEditingController?.text = ""; textEditingController.text = "";
} }
: null, : null,
child: const Text("Save"), child: const Text("Save"),
@ -96,6 +82,7 @@ class _SettingsState extends State<Settings> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (!Platform.isAndroid && !Platform.isIOS) ...[
SettingsHotKeyTile( SettingsHotKeyTile(
title: "Next track global shortcut", title: "Next track global shortcut",
currentHotKey: preferences.nextTrackHotKey, currentHotKey: preferences.nextTrackHotKey,
@ -117,12 +104,13 @@ class _SettingsState extends State<Settings> {
preferences.setPlayPauseHotKey(value); preferences.setPlayPauseHotKey(value);
}, },
), ),
],
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text("Theme"), const Text("Theme"),
DropdownButton<ThemeMode>( DropdownButton<ThemeMode>(
value: MyApp.of(context)?.getThemeMode(), value: theme,
items: const [ items: const [
DropdownMenuItem( DropdownMenuItem(
child: Text( child: Text(
@ -143,7 +131,7 @@ class _SettingsState extends State<Settings> {
], ],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
MyApp.of(context)?.setThemeMode(value); ref.read(themeProvider.notifier).state = value;
} }
}, },
) )
@ -151,7 +139,7 @@ class _SettingsState extends State<Settings> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Builder(builder: (context) { Builder(builder: (context) {
var auth = context.read<Auth>(); Auth auth = ref.watch(authProvider);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -166,7 +154,7 @@ class _SettingsState extends State<Settings> {
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
await localStorage.clear(); await localStorage.clear();
auth.logout(); auth.logout();
Navigator.of(context).pop(); GoRouter.of(context).pop();
}, },
), ),
], ],
@ -186,8 +174,8 @@ class _SettingsState extends State<Settings> {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.center, alignment: WrapAlignment.center,
children: const [ children: const [
Hyperlink( Hyperlink(
"💚 Sponsor/Donate 💚", "💚 Sponsor/Donate 💚",
@ -210,6 +198,7 @@ class _SettingsState extends State<Settings> {
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class AnchorButton<T> extends StatefulWidget { class AnchorButton<T> extends HookWidget {
final String text; final String text;
final TextStyle style; final TextStyle style;
final TextAlign? textAlign; final TextAlign? textAlign;
@ -16,33 +17,29 @@ class AnchorButton<T> extends StatefulWidget {
this.style = const TextStyle(), this.style = const TextStyle(),
}) : super(key: key); }) : super(key: key);
@override
State<AnchorButton<T>> createState() => _AnchorButtonState<T>();
}
class _AnchorButtonState<T> extends State<AnchorButton<T>> {
bool _hover = false;
bool _tap = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var hover = useState(false);
var tap = useState(false);
return GestureDetector( return GestureDetector(
child: MouseRegion( child: MouseRegion(
cursor: MaterialStateMouseCursor.clickable, cursor: MaterialStateMouseCursor.clickable,
child: Text( child: Text(
widget.text, text,
style: widget.style.copyWith( style: style.copyWith(
decoration: _hover || _tap ? TextDecoration.underline : null, decoration:
hover.value || tap.value ? TextDecoration.underline : null,
), ),
textAlign: widget.textAlign, textAlign: textAlign,
overflow: widget.overflow, overflow: overflow,
), ),
onEnter: (event) => setState(() => _hover = true), onEnter: (event) => hover.value = true,
onExit: (event) => setState(() => _hover = false), onExit: (event) => hover.value = false,
), ),
onTapDown: (event) => setState(() => _tap = true), onTapDown: (event) => tap.value = true,
onTapUp: (event) => setState(() => _tap = false), onTapUp: (event) => tap.value = false,
onTap: widget.onTap, onTap: onTap,
); );
} }
} }

View File

@ -1,66 +1,48 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/helpers/artist-to-string.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:path_provider/path_provider.dart' as path_provider; import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
class DownloadTrackButton extends StatefulWidget { enum TrackStatus { downloading, idle, done }
class DownloadTrackButton extends HookWidget {
final Track? track; final Track? track;
const DownloadTrackButton({Key? key, this.track}) : super(key: key); const DownloadTrackButton({Key? key, this.track}) : super(key: key);
@override @override
_DownloadTrackButtonState createState() => _DownloadTrackButtonState(); Widget build(BuildContext context) {
} var status = useState<TrackStatus>(TrackStatus.idle);
YoutubeExplode yt = useMemoized(() => YoutubeExplode());
enum TrackStatus { downloading, idle, done } var _downloadTrack = useCallback(() async {
if (track == null) return;
class _DownloadTrackButtonState extends State<DownloadTrackButton> {
late YoutubeExplode yt;
TrackStatus status = TrackStatus.idle;
@override
void initState() {
yt = YoutubeExplode();
super.initState();
}
@override
void dispose() {
yt.close();
super.dispose();
}
_downloadTrack() async {
if (widget.track == null) return;
StreamManifest manifest = StreamManifest manifest =
await yt.videos.streamsClient.getManifest(widget.track?.href); await yt.videos.streamsClient.getManifest(track?.href);
var audioStream = yt.videos.streamsClient var audioStream = yt.videos.streamsClient.get(
.get(manifest.audioOnly.withHighestBitrate()) manifest.audioOnly
.asBroadcastStream(); .where((audio) => audio.codec.mimeType == "audio/mp4")
.withHighestBitrate(),
);
var statusCb = audioStream.listen( var statusCb = audioStream.listen(
(event) { (event) {
if (status != TrackStatus.downloading) { if (status.value != TrackStatus.downloading) {
setState(() { status.value = TrackStatus.downloading;
status = TrackStatus.downloading;
});
} }
}, },
onDone: () async { onDone: () async {
setState(() { status.value = TrackStatus.done;
status = TrackStatus.done;
});
await Future.delayed( await Future.delayed(
const Duration(seconds: 3), const Duration(seconds: 3),
() { () {
if (status == TrackStatus.done) { if (status.value == TrackStatus.done) {
setState(() { status.value = TrackStatus.idle;
status = TrackStatus.idle;
});
} }
}, },
); );
@ -70,7 +52,7 @@ class _DownloadTrackButtonState extends State<DownloadTrackButton> {
String downloadFolder = path.join( String downloadFolder = path.join(
(await path_provider.getDownloadsDirectory())!.path, "Spotube"); (await path_provider.getDownloadsDirectory())!.path, "Spotube");
String fileName = String fileName =
"${widget.track?.name} - ${artistsToString<Artist>(widget.track?.artists ?? [])}.mp3"; "${track?.name} - ${artistsToString<Artist>(track?.artists ?? [])}.mp3";
File outputFile = File(path.join(downloadFolder, fileName)); File outputFile = File(path.join(downloadFolder, fileName));
if (!outputFile.existsSync()) { if (!outputFile.existsSync()) {
outputFile.createSync(recursive: true); outputFile.createSync(recursive: true);
@ -78,17 +60,13 @@ class _DownloadTrackButtonState extends State<DownloadTrackButton> {
await audioStream.pipe(outputFileStream); await audioStream.pipe(outputFileStream);
await outputFileStream.flush(); await outputFileStream.flush();
await outputFileStream.close().then((value) async { await outputFileStream.close().then((value) async {
if (status == TrackStatus.downloading) { if (status.value == TrackStatus.downloading) {
setState(() { status.value = TrackStatus.done;
status = TrackStatus.done;
});
await Future.delayed( await Future.delayed(
const Duration(seconds: 3), const Duration(seconds: 3),
() { () {
if (status == TrackStatus.done) { if (status.value == TrackStatus.done) {
setState(() { status.value = TrackStatus.idle;
status = TrackStatus.idle;
});
} }
}, },
); );
@ -96,11 +74,13 @@ class _DownloadTrackButtonState extends State<DownloadTrackButton> {
return statusCb.cancel(); return statusCb.cancel();
}); });
} }
} }, [track, status, yt]);
@override useEffect(() {
Widget build(BuildContext context) { return () => yt.close();
if (status == TrackStatus.downloading) { }, []);
if (status.value == TrackStatus.downloading) {
return const SizedBox( return const SizedBox(
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
strokeWidth: 2, strokeWidth: 2,
@ -108,13 +88,13 @@ class _DownloadTrackButtonState extends State<DownloadTrackButton> {
height: 20, height: 20,
width: 20, width: 20,
); );
} else if (status == TrackStatus.done) { } else if (status.value == TrackStatus.done) {
return const Icon(Icons.download_done_rounded); return const Icon(Icons.download_done_rounded);
} }
return IconButton( return IconButton(
icon: const Icon(Icons.download_rounded), icon: const Icon(Icons.download_rounded),
onPressed: widget.track != null && onPressed: track != null &&
!(widget.track!.href ?? "").startsWith("https://api.spotify.com") !(track!.href ?? "").startsWith("https://api.spotify.com")
? _downloadTrack ? _downloadTrack
: null, : null,
); );

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:spotube/components/Shared/AnchorButton.dart'; import 'package:spotube/components/Shared/AnchorButton.dart';
class LinkText<T> extends StatelessWidget { class LinkText<T> extends StatelessWidget {
@ -6,12 +7,14 @@ class LinkText<T> extends StatelessWidget {
final TextStyle style; final TextStyle style;
final TextAlign? textAlign; final TextAlign? textAlign;
final TextOverflow? overflow; final TextOverflow? overflow;
final Route<T> route; final String route;
final T? extra;
const LinkText( const LinkText(
this.text, this.text,
this.route, { this.route, {
Key? key, Key? key,
this.textAlign, this.textAlign,
this.extra,
this.overflow, this.overflow,
this.style = const TextStyle(), this.style = const TextStyle(),
}) : super(key: key); }) : super(key: key);
@ -20,8 +23,8 @@ class LinkText<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnchorButton( return AnchorButton(
text, text,
onTap: () async { onTap: () {
await Navigator.of(context).push(route); GoRouter.of(context).push(route, extra: extra);
}, },
key: key, key: key,
overflow: overflow, overflow: overflow,

View File

@ -52,10 +52,23 @@ class PageWindowTitleBar extends StatelessWidget
const PageWindowTitleBar({Key? key, this.leading, this.center}) const PageWindowTitleBar({Key? key, this.leading, this.center})
: super(key: key); : super(key: key);
@override @override
Size get preferredSize => Size.fromHeight(appWindow.titleBarHeight); Size get preferredSize => Size.fromHeight(
!Platform.isIOS && !Platform.isAndroid ? appWindow.titleBarHeight : 35,
);
@override @override
Widget build(BuildContext context) { 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( return WindowTitleBarBox(
child: Row( child: Row(
children: [ children: [
@ -65,7 +78,8 @@ class PageWindowTitleBar extends StatelessWidget
), ),
if (leading != null) leading!, if (leading != null) leading!,
Expanded(child: MoveWindow(child: Center(child: center))), Expanded(child: MoveWindow(child: Center(child: center))),
if (!Platform.isMacOS) const TitleBarActionButtons() if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid)
const TitleBarActionButtons()
], ],
), ),
); );

View File

@ -5,6 +5,7 @@ class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap; final void Function()? onTap;
final void Function()? onPlaybuttonPressed; final void Function()? onPlaybuttonPressed;
final String? description; final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl; final String imageUrl;
final bool isPlaying; final bool isPlaying;
final String title; final String title;
@ -12,6 +13,7 @@ class PlaybuttonCard extends StatelessWidget {
required this.imageUrl, required this.imageUrl,
required this.isPlaying, required this.isPlaying,
required this.title, required this.title,
this.margin,
this.description, this.description,
this.onPlaybuttonPressed, this.onPlaybuttonPressed,
this.onTap, this.onTap,
@ -20,7 +22,9 @@ class PlaybuttonCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return Container(
margin: margin,
child: InkWell(
onTap: onTap, onTap: onTap,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), constraints: const BoxConstraints(maxWidth: 200),
@ -46,11 +50,8 @@ class PlaybuttonCard extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
progressIndicatorBuilder: (context, url, progress) { placeholder: (context, url) =>
return CircularProgressIndicator.adaptive( Image.asset("assets/placeholder.png"),
value: progress.progress,
);
},
), ),
), ),
Positioned.directional( Positioned.directional(
@ -84,9 +85,13 @@ class PlaybuttonCard extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 8, vertical: 10), const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column( child: Column(
children: [ children: [
Text( Tooltip(
message: title,
child: Text(
title, title,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
), ),
if (description != null) ...[ if (description != null) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
@ -105,6 +110,7 @@ class PlaybuttonCard extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; 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'; import 'package:hotkey_manager/hotkey_manager.dart';
class RecordHotKeyDialog extends StatefulWidget { class RecordHotKeyDialog extends HookWidget {
final ValueChanged<HotKey> onHotKeyRecorded; final ValueChanged<HotKey> onHotKeyRecorded;
const RecordHotKeyDialog({ const RecordHotKeyDialog({
@ -9,15 +11,9 @@ class RecordHotKeyDialog extends StatefulWidget {
required this.onHotKeyRecorded, required this.onHotKeyRecorded,
}) : super(key: key); }) : super(key: key);
@override
_RecordHotKeyDialogState createState() => _RecordHotKeyDialogState();
}
class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
HotKey _hotKey = HotKey(null);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var _hotKey = useState(HotKey(null));
return AlertDialog( return AlertDialog(
content: SingleChildScrollView( content: SingleChildScrollView(
child: ListBody( child: ListBody(
@ -58,9 +54,7 @@ class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
children: [ children: [
HotKeyRecorder( HotKeyRecorder(
onHotKeyRecorded: (hotKey) { onHotKeyRecorded: (hotKey) {
setState(() { _hotKey.value = hotKey;
_hotKey = hotKey;
});
}, },
), ),
], ],
@ -73,16 +67,16 @@ class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
TextButton( TextButton(
child: const Text('Cancel'), child: const Text('Cancel'),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); GoRouter.of(context).pop();
}, },
), ),
TextButton( TextButton(
child: const Text('OK'), child: const Text('OK'),
onPressed: !_hotKey.isSetted onPressed: !_hotKey.value.isSetted
? null ? null
: () { : () {
widget.onHotKeyRecorded(_hotKey); onHotKeyRecorded(_hotKey.value);
Navigator.of(context).pop(); GoRouter.of(context).pop();
}, },
), ),
], ],

View File

@ -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<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(
opacity: animation,
child: child,
);
}
}
class SpotubePage extends CustomTransitionPage {
SpotubePage({
required Widget child,
}) : super(
child: child,
transitionsBuilder: (BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
}

View File

@ -1,106 +1,31 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumView.dart'; import 'package:spotube/components/Album/AlbumView.dart';
import 'package:spotube/components/Shared/LinkText.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/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
class TracksTableView extends StatelessWidget { class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed; final void Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks; final List<Track> tracks;
const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed}) const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed})
: super(key: key); : super(key: key);
static Widget buildTrackTile(
BuildContext context,
Playback playback, {
required MapEntry<int, Track> 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 @override
Widget build(BuildContext context) { Widget build(context, ref) {
Playback playback = context.watch<Playback>(); Playback playback = ref.watch(playbackProvider);
TextStyle tableHeadStyle = TextStyle tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final breakpoint = useBreakpoints();
return Expanded( return Expanded(
child: Scrollbar( child: Scrollbar(
child: ListView( child: ListView(
@ -127,6 +52,7 @@ class TracksTableView extends StatelessWidget {
), ),
), ),
// used alignment of this table-head // used alignment of this table-head
if (breakpoint.isMoreThan(Breakpoints.md)) ...[
const SizedBox(width: 100), const SizedBox(width: 100),
Expanded( Expanded(
child: Row( child: Row(
@ -138,10 +64,13 @@ class TracksTableView extends StatelessWidget {
), ),
], ],
), ),
), )
],
if (!breakpoint.isSm) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
Text("Time", style: tableHeadStyle), Text("Time", style: tableHeadStyle),
const SizedBox(width: 10), const SizedBox(width: 10),
]
], ],
), ),
...tracks.asMap().entries.map((track) { ...tracks.asMap().entries.map((track) {
@ -151,11 +80,13 @@ class TracksTableView extends StatelessWidget {
); );
String duration = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return buildTrackTile(context, playback, return TrackTile(
playback,
track: track, track: track,
duration: duration, duration: duration,
thumbnailUrl: thumbnailUrl, thumbnailUrl: thumbnailUrl,
onTrackPlayButtonPressed: onTrackPlayButtonPressed); onTrackPlayButtonPressed: onTrackPlayButtonPressed,
);
}).toList() }).toList()
], ],
), ),
@ -163,3 +94,103 @@ class TracksTableView extends StatelessWidget {
); );
} }
} }
class TrackTile extends HookWidget {
final Playback playback;
final MapEntry<int, Track> 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)
],
],
);
}
}

View File

@ -1,16 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistProfile.dart';
import 'package:spotube/components/Shared/LinkText.dart'; import 'package:spotube/components/Shared/LinkText.dart';
Widget artistsToClickableArtists( Widget artistsToClickableArtists(
List<ArtistSimple> artists, { List<ArtistSimple> artists, {
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center, WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start, WrapAlignment mainAxisAlignment = WrapAlignment.center,
TextStyle textStyle = const TextStyle(),
}) { }) {
return Row( return Wrap(
crossAxisAlignment: crossAxisAlignment, crossAxisAlignment: crossAxisAlignment,
mainAxisAlignment: mainAxisAlignment, alignment: mainAxisAlignment,
children: artists children: artists
.asMap() .asMap()
.entries .entries
@ -19,10 +19,9 @@ Widget artistsToClickableArtists(
(artist.key != artists.length - 1) (artist.key != artists.length - 1)
? "${artist.value.name}, " ? "${artist.value.name}, "
: artist.value.name!, : artist.value.name!,
MaterialPageRoute<ArtistProfile>( "/artist/${artist.value.id}",
builder: (context) => ArtistProfile(artist.value.id!),
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textStyle,
), ),
) )
.toList(), .toList(),

View File

@ -1,7 +1,9 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:uuid/uuid.dart' show Uuid;
const uuid = Uuid();
String imageToUrlString(List<Image>? images, {int index = 0}) { String imageToUrlString(List<Image>? images, {int index = 0}) {
return images != null && images.isNotEmpty return images != null && images.isNotEmpty
? images[0].url! ? images[0].url!
: "https://avatars.dicebear.com/api/croodles-neutral/${DateTime.now().toString()}.png"; : "https://avatars.dicebear.com/api/bottts/${uuid.v4()}.png";
} }

View File

@ -1,15 +1,13 @@
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.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/helpers/server_ipc.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
const redirectUri = "http://localhost:4304/auth/spotify/callback"; const redirectUri = "http://localhost:4304/auth/spotify/callback";
Future<void> oauthLogin(BuildContext context, Future<void> oauthLogin(Auth auth,
{required String clientId, required String clientSecret}) async { {required String clientId, required String clientSecret}) async {
try { try {
String? accessToken; String? accessToken;
@ -50,7 +48,7 @@ Future<void> oauthLogin(BuildContext context,
clientSecret, clientSecret,
); );
Provider.of<Auth>(context, listen: false).setAuthState( auth.setAuthState(
clientId: clientId, clientId: clientId,
clientSecret: clientSecret, clientSecret: clientSecret,
accessToken: accessToken, accessToken: accessToken,

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -21,7 +23,16 @@ Future<Track> toYoutubeTrack(YoutubeExplode youtube, Track track) async {
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id); 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; track.href = ytVideo.url;
return track; return track;
} }

41
lib/hooks/playback.dart Normal file
View File

@ -0,0 +1,41 @@
import 'package:spotube/provider/Playback.dart';
Future<void> 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<void> 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<void> 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);
}
};
}

View File

@ -0,0 +1,18 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
void useAsyncEffect(
FutureOr<dynamic> Function() effect, [
FutureOr<dynamic> Function()? cleanup,
List<Object>? keys,
]) {
useEffect(() {
Future.microtask(effect);
return () {
if (cleanup != null) {
Future.microtask(cleanup);
}
};
}, keys);
}

View File

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

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class BreakpointUtils {
Breakpoints breakpoint;
List<Breakpoints> 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;
}

48
lib/hooks/useHotKeys.dart Normal file
View File

@ -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<GlobalKeyActions> _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)));
};
});
}

View File

@ -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<bool?>(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;
}

View File

@ -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<PageKeyType, ItemType>
usePagingController<PageKeyType, ItemType>({
required final PageKeyType firstPageKey,
final int? invisibleItemsThreshold,
List<Object?>? keys,
}) {
return use(
_PagingControllerHook<PageKeyType, ItemType>(
firstPageKey: firstPageKey,
invisibleItemsThreshold: invisibleItemsThreshold,
keys: keys,
),
);
}
class _PagingControllerHook<PageKeyType, ItemType>
extends Hook<PagingController<PageKeyType, ItemType>> {
const _PagingControllerHook({
required this.firstPageKey,
this.invisibleItemsThreshold,
List<Object?>? keys,
}) : super(keys: keys);
final PageKeyType firstPageKey;
final int? invisibleItemsThreshold;
@override
HookState<PagingController<PageKeyType, ItemType>,
Hook<PagingController<PageKeyType, ItemType>>>
createState() => _PagingControllerHookState<PageKeyType, ItemType>();
}
class _PagingControllerHookState<PageKeyType, ItemType> extends HookState<
PagingController<PageKeyType, ItemType>,
_PagingControllerHook<PageKeyType, ItemType>> {
late final controller = PagingController<PageKeyType, ItemType>(
firstPageKey: hook.firstPageKey,
invisibleItemsThreshold: hook.invisibleItemsThreshold);
@override
PagingController<PageKeyType, ItemType> build(BuildContext context) =>
controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'usePagingController';
}

View File

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

View File

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

View File

@ -1,121 +1,68 @@
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.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:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart'; import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/components/Home.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/YouTube.dart';
import 'package:spotube/provider/UserPreferences.dart';
void main() async { void main() async {
if (!Platform.isAndroid && !Platform.isIOS) {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await hotKeyManager.unregisterAll(); await hotKeyManager.unregisterAll();
runApp(MyApp());
doWhenWindowReady(() { doWhenWindowReady(() {
appWindow.minSize = const Size(900, 700); appWindow.minSize =
Size(Platform.isAndroid || Platform.isIOS ? 280 : 359, 700);
appWindow.size = const Size(900, 700); appWindow.size = const Size(900, 700);
appWindow.alignment = Alignment.center; appWindow.alignment = Alignment.center;
appWindow.maximize(); appWindow.maximize();
appWindow.show(); appWindow.show();
}); });
} }
runApp(ProviderScope(child: MyApp()));
class MyApp extends StatefulWidget {
static _MyAppState? of(BuildContext context) =>
context.findAncestorStateOfType<_MyAppState>();
@override
State<MyApp> createState() => _MyAppState();
} }
class _MyAppState extends State<MyApp> { class MyApp extends HookConsumerWidget {
ThemeMode _themeMode = ThemeMode.system; final GoRouter _router = createGoRouter();
MyApp({Key? key}) : super(key: key);
@override @override
void initState() { Widget build(BuildContext context, ref) {
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async { var themeMode = ref.watch(themeProvider);
SharedPreferences localStorage = await SharedPreferences.getInstance(); var player = ref.watch(audioPlayerProvider);
var youtube = ref.watch(youtubeProvider);
useEffect(() {
SharedPreferences.getInstance().then((localStorage) {
String? themeMode = localStorage.getString(LocalStorageKeys.themeMode); String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
var themeNotifier = ref.read(themeProvider.notifier);
setState(() {
switch (themeMode) { switch (themeMode) {
case "light": case "light":
_themeMode = ThemeMode.light; themeNotifier.state = ThemeMode.light;
break; break;
case "dark": case "dark":
_themeMode = ThemeMode.dark; themeNotifier.state = ThemeMode.dark;
break; break;
default: default:
_themeMode = ThemeMode.system; themeNotifier.state = ThemeMode.system;
} }
}); });
}); return () {
super.initState(); player.dispose();
} youtube.close();
};
}, []);
void setThemeMode(ThemeMode themeMode) { return MaterialApp.router(
SharedPreferences.getInstance().then((localStorage) { routeInformationParser: _router.routeInformationParser,
localStorage.setString( routerDelegate: _router.routerDelegate,
LocalStorageKeys.themeMode, themeMode.toString().split(".").last);
setState(() {
_themeMode = themeMode;
});
});
}
ThemeMode getThemeMode() {
return _themeMode;
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<Auth>(create: (context) => Auth()),
ChangeNotifierProvider<SpotifyDI>(create: (context) {
Auth authState = Provider.of<Auth>(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<Playback>(create: (context) => Playback()),
ChangeNotifierProvider<UserPreferences>(
create: (context) {
return UserPreferences();
},
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Spotube', title: 'Spotube',
theme: ThemeData( theme: ThemeData(
@ -160,6 +107,10 @@ class _MyAppState extends State<MyApp> {
color: Colors.grey[850], color: Colors.grey[850],
), ),
), ),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.blueGrey[50],
height: 55,
),
cardTheme: CardTheme( cardTheme: CardTheme(
shape: shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
@ -194,6 +145,10 @@ class _MyAppState extends State<MyApp> {
backgroundColor: Colors.blueGrey[800], backgroundColor: Colors.blueGrey[800],
unselectedIconTheme: const IconThemeData(opacity: 1), unselectedIconTheme: const IconThemeData(opacity: 1),
), ),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.blueGrey[800],
height: 55,
),
cardTheme: CardTheme( cardTheme: CardTheme(
shape: shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
@ -202,9 +157,7 @@ class _MyAppState extends State<MyApp> {
), ),
canvasColor: Colors.blueGrey[900], canvasColor: Colors.blueGrey[900],
), ),
themeMode: _themeMode, themeMode: themeMode,
home: const Home(),
),
); );
} }
} }

View File

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

View File

@ -10,4 +10,6 @@ abstract class LocalStorageKeys {
static String nextTrackHotKey = "next_track_hot_key"; static String nextTrackHotKey = "next_track_hot_key";
static String prevTrackHotKey = "prev_track_hot_key"; static String prevTrackHotKey = "prev_track_hot_key";
static String playPauseHotKey = "play_pause_hot_key"; static String playPauseHotKey = "play_pause_hot_key";
static String volume = "volume";
} }

View File

@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
final audioPlayerProvider = Provider<AudioPlayer>((ref) {
return AudioPlayer();
});

View File

@ -1,4 +1,5 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class Auth with ChangeNotifier { class Auth with ChangeNotifier {
String? _clientId; String? _clientId;
@ -51,4 +52,11 @@ class Auth with ChangeNotifier {
_isLoggedIn = false; _isLoggedIn = false;
notifyListeners(); notifyListeners();
} }
@override
String toString() {
return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
} }
}
var authProvider = ChangeNotifierProvider<Auth>((ref) => Auth());

View File

@ -1,5 +1,14 @@
import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/cupertino.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: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 { class CurrentPlaylist {
List<Track>? _tempTrack; List<Track>? _tempTrack;
@ -7,6 +16,7 @@ class CurrentPlaylist {
String id; String id;
String name; String name;
String thumbnail; String thumbnail;
CurrentPlaylist({ CurrentPlaylist({
required this.tracks, required this.tracks,
required this.id, required this.id,
@ -36,13 +46,105 @@ class CurrentPlaylist {
class Playback extends ChangeNotifier { class Playback extends ChangeNotifier {
CurrentPlaylist? _currentPlaylist; CurrentPlaylist? _currentPlaylist;
Track? _currentTrack; Track? _currentTrack;
Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) {
_currentPlaylist = currentPlaylist; // states
_currentTrack = currentTrack; 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<Function(Duration?)> _durationListeners = [];
// listeners
StreamSubscription<bool>? _playingStreamListener;
StreamSubscription<Duration?>? _durationStreamListener;
StreamSubscription<ProcessingState>? _processingStateStreamListener;
StreamSubscription<AudioInterruptionEvent>? _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; CurrentPlaylist? get currentPlaylist => _currentPlaylist;
Track? get currentTrack => _currentTrack; 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) { set setCurrentTrack(Track track) {
_currentTrack = track; _currentTrack = track;
@ -54,7 +156,10 @@ class Playback extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
reset() { void reset() {
_isPlaying = false;
_duration = null;
_callAllDurationListeners(null);
_currentPlaylist = null; _currentPlaylist = null;
_currentTrack = null; _currentTrack = null;
notifyListeners(); notifyListeners();
@ -75,6 +180,82 @@ class Playback extends ChangeNotifier {
return false; return false;
} }
} }
@override
dispose() {
_processingStateStreamListener?.cancel();
_durationStreamListener?.cancel();
_playingStreamListener?.cancel();
_audioInterruptionEventListener?.cancel();
_audioSession?.setActive(false);
super.dispose();
} }
var x = Playback(); 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<void> 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);
}
}
}
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
final player = ref.watch(audioPlayerProvider);
final youtube = ref.watch(youtubeProvider);
return Playback(player: player, youtube: youtube);
});

View File

@ -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: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 { var spotifyProvider = Provider<SpotifyApi>((ref) {
SpotifyApi _spotifyApi; Auth authState = ref.watch(authProvider);
SpotifyDI(this._spotifyApi); return SpotifyApi(
SpotifyApiCredentials(
SpotifyApi get spotifyApi => _spotifyApi; 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!,
);
},
);
});

View File

@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
var themeProvider = StateProvider<ThemeMode>((ref) {
return ThemeMode.system;
});

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/LocalStorageKeys.dart';
@ -110,3 +111,5 @@ class UserPreferences extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
var userPreferencesProvider = ChangeNotifierProvider((_) => UserPreferences());

View File

@ -0,0 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
final youtubeProvider = Provider<YoutubeExplode>((ref) => YoutubeExplode());

View File

@ -14,7 +14,7 @@ packages:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.8" version: "3.2.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -30,7 +30,7 @@ packages:
source: hosted source: hosted
version: "2.8.2" version: "2.8.2"
audio_session: audio_session:
dependency: transitive dependency: "direct main"
description: description:
name: audio_session name: audio_session
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@ -121,7 +121,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@ -180,7 +180,7 @@ packages:
name: flutter_blurhash name: flutter_blurhash
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.0" version: "0.6.4"
flutter_cache_manager: flutter_cache_manager:
dependency: transitive dependency: transitive
description: description:
@ -188,6 +188,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -195,6 +202,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -211,7 +225,21 @@ packages:
name: freezed_annotation name: freezed_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted 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: hotkey_manager:
dependency: "direct main" dependency: "direct main"
description: description:
@ -246,7 +274,7 @@ packages:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.1" version: "3.1.3"
infinite_scroll_pagination: infinite_scroll_pagination:
dependency: "direct main" dependency: "direct main"
description: description:
@ -254,13 +282,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
injector:
dependency: transitive
description:
name: injector
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -281,7 +302,7 @@ packages:
name: just_audio name: just_audio
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.9.18" version: "0.9.20"
just_audio_libwinmedia: just_audio_libwinmedia:
dependency: "direct main" dependency: "direct main"
description: description:
@ -295,14 +316,14 @@ packages:
name: just_audio_platform_interface name: just_audio_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.0" version: "4.1.0"
just_audio_web: just_audio_web:
dependency: transitive dependency: transitive
description: description:
name: just_audio_web name: just_audio_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.7"
libwinmedia: libwinmedia:
dependency: transitive dependency: transitive
description: description:
@ -317,6 +338,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -344,14 +372,7 @@ packages:
name: msix name: msix
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.8.1" version: "2.8.18"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
oauth2: oauth2:
dependency: transitive dependency: transitive
description: description:
@ -373,6 +394,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" 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: path:
dependency: "direct main" dependency: "direct main"
description: description:
@ -386,7 +414,7 @@ packages:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.8" version: "2.0.9"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
@ -407,28 +435,28 @@ packages:
name: path_provider_linux name: path_provider_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
path_provider_macos: path_provider_macos:
dependency: transitive dependency: transitive
description: description:
name: path_provider_macos name: path_provider_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.1" version: "2.0.3"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
pedantic: pedantic:
dependency: transitive dependency: transitive
description: description:
@ -456,7 +484,7 @@ packages:
name: plugin_platform_interface name: plugin_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.1.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -464,13 +492,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.2.4" version: "4.2.4"
provider: riverpod:
dependency: "direct main" dependency: transitive
description: description:
name: provider name: riverpod
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.1" version: "1.0.3"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -484,35 +512,35 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.11" version: "2.0.13"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.9" version: "2.0.11"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_ios name: shared_preferences_ios
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.8" version: "2.1.0"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.3" version: "2.1.0"
shared_preferences_macos: shared_preferences_macos:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_macos name: shared_preferences_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.0.3"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -526,14 +554,14 @@ packages:
name: shared_preferences_web name: shared_preferences_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.0.3"
shared_preferences_windows: shared_preferences_windows:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.3" version: "2.1.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -566,14 +594,14 @@ packages:
name: sqflite name: sqflite
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.1" version: "2.0.2"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.2.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -581,6 +609,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.10.0" 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: stream_channel:
dependency: transitive dependency: transitive
description: description:
@ -629,63 +664,63 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.17" version: "6.0.20"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.13" version: "6.0.15"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.13" version: "6.0.15"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "3.0.0"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "3.0.0"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_platform_interface name: url_launcher_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
url_launcher_web: url_launcher_web:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.0.8"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "3.0.0"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
name: uuid name: uuid
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.5" version: "3.0.6"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -699,14 +734,14 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.3" version: "2.4.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
name: xdg_directories name: xdg_directories
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.0+1"
xml: xml:
dependency: transitive dependency: transitive
description: description:
@ -727,7 +762,7 @@ packages:
name: youtube_explode_dart name: youtube_explode_dart
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.10.8" version: "1.10.9+1"
sdks: sdks:
dart: ">=2.15.1 <3.0.0" dart: ">=2.15.1 <3.0.0"
flutter: ">=2.5.0" flutter: ">=2.10.0"

View File

@ -37,7 +37,6 @@ dependencies:
cached_network_image: ^3.2.0 cached_network_image: ^3.2.0
html: ^0.15.0 html: ^0.15.0
http: ^0.13.4 http: ^0.13.4
provider: ^6.0.1
shared_preferences: ^2.0.11 shared_preferences: ^2.0.11
spotify: ^0.6.0 spotify: ^0.6.0
url_launcher: ^6.0.17 url_launcher: ^6.0.17
@ -49,6 +48,13 @@ dependencies:
just_audio_libwinmedia: ^0.0.4 just_audio_libwinmedia: ^0.0.4
path: ^1.8.0 path: ^1.8.0
path_provider: ^2.0.8 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: dev_dependencies:
flutter_test: flutter_test: