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"/>
</a>
<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 href="https://github.com/KRTirtho">
<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
Download the [setup file](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-windows-x86_64-setup.exe) & follow along the installer
Download the [setup file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-windows-x86_64-setup.exe) & follow along the installer
### Chocolatey
@ -71,7 +71,7 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
<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:
Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.deb) then double click it or run
Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.deb) then double click it or run
```bash
$ sudo apt install Spotube-linux-x86_64.deb
# or
@ -91,10 +91,13 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
### AppImage:
Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey stores or software centers or repositories**
## Mac OS
Download the [Mac OS Disk Image (.dmg) file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg) from the release & follow along the setup wizard
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey/homebrew stores or software centers or repositories**
# Configuration
There are some configurations that needs to be done to start using this software
@ -130,7 +133,7 @@ Also, you need a [genius](https://genius.com) account for **lyrics** & a API Cli
- [x] Add support for show Lyric of currently playing track
- [x] Track download
- [ ] Support for playing/streaming podcasts/shows
- [ ] Artist, User & Album pages
- [x] Artist, User & Album pages
# Building from source
@ -149,7 +152,6 @@ $ flutter run -d <window|macos|linux>
- Shows & Podcasts aren't supported as it'd require premium anyway
- OS Media Controls
- Global Media Shortcuts/Keyboard Media Buttons
# License
@ -178,4 +180,4 @@ Bu why? You can learn about it [here](https://dev.to/krtirtho/choosing-open-sour
Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about this application
<p align="center">&copy; 2022 Spotube</p>
<p align="center">&copy; 2022 Spotube</p>

View File

@ -1,34 +1,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="oss.krtirtho.spotube">
<application
android:label="spotube"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<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
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="oss.krtirtho.spotube">
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<!-- If your app opens https URLs -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application android:label="spotube" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">
<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
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
</manifest>

View File

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

View File

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

View File

@ -7,7 +7,7 @@ in verifying that this package's contents are trustworthy.
Please go to releases page
https://github.com/KRTirtho/spotube/releases
Download same version as this choco package (example for v1.1.0)
Download same version as this choco package (example for v1.2.0)
https://github.com/KRTirtho/spotube/releases/tag/v1.0.1
1. get hashes. Run:
@ -15,9 +15,9 @@ powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe
2. The checksums should match the following:
---
Version Hashes for v1.1.0
Version Hashes for v1.2.0
Algorithm Hash Path
--------- ---- ----
SHA256 144fb4170b424ae9ecee8941354244cb9744c0913fdc69f730a8b5e40e56753d tools\Spotube-windows-x86_64-setup.exe
SHA256 02c032e1a2b8f60969b7a65c6a5e21df2bf5834cc8d8062cf56a2c8245a2a90e tools\Spotube-windows-x86_64-setup.exe

View File

@ -37,5 +37,11 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'AUDIO_SESSION_MICROPHONE=0'
]
end
end
end

View File

@ -1,47 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
</dict>
</plist>

View File

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

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
@ -8,11 +8,12 @@ import 'package:spotube/helpers/simple-track-to-track.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class AlbumView extends StatelessWidget {
class AlbumView extends ConsumerWidget {
final AlbumSimple album;
const AlbumView(this.album, {Key? key}) : super(key: key);
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) {
playPlaylist(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
if (!isPlaylistPlaying) {
@ -28,71 +29,74 @@ class AlbumView extends StatelessWidget {
currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
}
await playback.startPlaying();
}
@override
Widget build(BuildContext context) {
Playback playback = context.watch<Playback>();
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
return Scaffold(
body: FutureBuilder<Iterable<TrackSimple>>(
future: spotify.albums.getTracks(album.id!).all(),
builder: (context, snapshot) {
List<Track> tracks = snapshot.data?.map((trackSmp) {
return simpleTrackToTrack(trackSmp, album);
}).toList() ??
[];
return Column(
children: [
PageWindowTitleBar(
leading: Row(
children: [
// nav back
const BackButton(),
// heart playlist
IconButton(
icon: const Icon(Icons.favorite_outline_rounded),
onPressed: () {},
),
// play playlist
IconButton(
icon: Icon(
isPlaylistPlaying
? Icons.stop_rounded
: Icons.play_arrow_rounded,
SpotifyApi spotify = ref.watch(spotifyProvider);
return SafeArea(
child: Scaffold(
body: FutureBuilder<Iterable<TrackSimple>>(
future: spotify.albums.getTracks(album.id!).all(),
builder: (context, snapshot) {
List<Track> tracks = snapshot.data?.map((trackSmp) {
return simpleTrackToTrack(trackSmp, album);
}).toList() ??
[];
return Column(
children: [
PageWindowTitleBar(
leading: Row(
children: [
// nav back
const BackButton(),
// heart playlist
IconButton(
icon: const Icon(Icons.favorite_outline_rounded),
onPressed: () {},
),
onPressed: snapshot.hasData
? () => playPlaylist(playback, tracks)
: null,
)
],
),
),
Center(
child: Text(album.name!,
style: Theme.of(context).textTheme.headline4),
),
snapshot.hasError
? const Center(child: Text("Error occurred"))
: !snapshot.hasData
? const Expanded(
child: Center(
child: CircularProgressIndicator.adaptive()),
)
: TracksTableView(
tracks,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist(
playback,
tracks,
currentTrack: currentTrack,
),
// play playlist
IconButton(
icon: Icon(
isPlaylistPlaying
? Icons.stop_rounded
: Icons.play_arrow_rounded,
),
],
);
}),
onPressed: snapshot.hasData
? () => playPlaylist(playback, tracks)
: null,
)
],
),
),
Center(
child: Text(album.name!,
style: Theme.of(context).textTheme.headline4),
),
snapshot.hasError
? const Center(child: Text("Error occurred"))
: !snapshot.hasData
? const Expanded(
child: Center(
child: CircularProgressIndicator.adaptive()),
)
: TracksTableView(
tracks,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist(
playback,
tracks,
currentTrack: currentTrack,
),
),
],
);
}),
),
);
}
}

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class ArtistAlbumView extends StatefulWidget {
class ArtistAlbumView extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
const ArtistAlbumView(
@ -16,10 +16,10 @@ class ArtistAlbumView extends StatefulWidget {
}) : super(key: key);
@override
State<ArtistAlbumView> createState() => _ArtistAlbumViewState();
ConsumerState<ArtistAlbumView> createState() => _ArtistAlbumViewState();
}
class _ArtistAlbumViewState extends State<ArtistAlbumView> {
class _ArtistAlbumViewState extends ConsumerState<ArtistAlbumView> {
final PagingController<int, Album> _pagingController =
PagingController<int, Album>(firstPageKey: 0);
@ -39,10 +39,9 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
_fetchPage(int pageKey) async {
try {
SpotifyDI data = context.read<SpotifyDI>();
Page<Album> albums = await data.spotifyApi.artists
.albums(widget.artistId)
.getPage(8, pageKey);
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
Page<Album> albums =
await spotifyApi.artists.albums(widget.artistId).getPage(8, pageKey);
var items = albums.items!.toList();
@ -60,32 +59,34 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()),
body: Column(
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headline4,
),
Expanded(
child: PagedGridView(
pagingController: _pagingController,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
childAspectRatio: 9 / 13,
crossAxisSpacing: 20,
mainAxisSpacing: 20,
),
padding: const EdgeInsets.all(10),
builderDelegate: PagedChildBuilderDelegate<Album>(
itemBuilder: (context, item, index) {
return AlbumCard(item);
},
return SafeArea(
child: Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()),
body: Column(
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headline4,
),
Expanded(
child: PagedGridView(
pagingController: _pagingController,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
childAspectRatio: 9 / 13,
crossAxisSpacing: 20,
mainAxisSpacing: 20,
),
padding: const EdgeInsets.all(10),
builderDelegate: PagedChildBuilderDelegate<Album>(
itemBuilder: (context, item, index) {
return AlbumCard(item);
},
),
),
),
),
],
],
),
),
);
}

View File

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistProfile.dart';
class ArtistCard extends StatelessWidget {
final Artist artist;
@ -9,13 +9,14 @@ class ArtistCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final backgroundImage = CachedNetworkImageProvider((artist
.images?.isNotEmpty ??
false)
? artist.images!.first.url!
: "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6");
return InkWell(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return ArtistProfile(artist.id!);
},
));
GoRouter.of(context).push("/artist/${artist.id}");
},
borderRadius: BorderRadius.circular(10),
child: Ink(
@ -38,11 +39,7 @@ class ArtistCard extends StatelessWidget {
CircleAvatar(
maxRadius: 80,
minRadius: 20,
backgroundImage: CachedNetworkImageProvider((artist
.images?.isNotEmpty ??
false)
? artist.images!.first.url!
: "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6"),
backgroundImage: backgroundImage,
),
Text(
artist.name!,

View File

@ -1,60 +1,83 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Artist/ArtistAlbumView.dart';
import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/readable-number.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class ArtistProfile extends StatefulWidget {
class ArtistProfile extends HookConsumerWidget {
final String artistId;
const ArtistProfile(this.artistId, {Key? key}) : super(key: key);
@override
_ArtistProfileState createState() => _ArtistProfileState();
}
Widget build(BuildContext context, ref) {
SpotifyApi spotify = ref.watch(spotifyProvider);
final scrollController = useScrollController();
final parentScrollController = useScrollController();
final textTheme = Theme.of(context).textTheme;
final chipTextVariant = useBreakpointValue(
sm: textTheme.bodySmall,
md: textTheme.bodyMedium,
lg: textTheme.headline6,
xl: textTheme.headline6,
xxl: textTheme.headline6,
);
class _ArtistProfileState extends State<ArtistProfile> {
@override
Widget build(BuildContext context) {
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
return Scaffold(
appBar: const PageWindowTitleBar(
leading: BackButton(),
),
body: FutureBuilder<Artist>(
future: spotify.artists.get(widget.artistId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final avatarWidth = useBreakpointValue(
sm: MediaQuery.of(context).size.width * 0.50,
md: MediaQuery.of(context).size.width * 0.40,
lg: MediaQuery.of(context).size.width * 0.18,
xl: MediaQuery.of(context).size.width * 0.18,
xxl: MediaQuery.of(context).size.width * 0.18,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const SizedBox(width: 50),
CircleAvatar(
radius: MediaQuery.of(context).size.width * 0.18,
backgroundImage: CachedNetworkImageProvider(
imageToUrlString(snapshot.data!.images),
final breakpoint = useBreakpoints();
return SafeArea(
child: Scaffold(
appBar: const PageWindowTitleBar(
leading: BackButton(),
),
body: FutureBuilder<Artist>(
future: spotify.artists.get(artistId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());
}
return SingleChildScrollView(
controller: parentScrollController,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [
const SizedBox(width: 50),
CircleAvatar(
radius: avatarWidth,
backgroundImage: CachedNetworkImageProvider(
imageToUrlString(snapshot.data!.images),
),
),
),
Flexible(
child: Padding(
Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
@ -64,21 +87,24 @@ class _ArtistProfileState extends State<ArtistProfile> {
color: Colors.blue,
borderRadius: BorderRadius.circular(50)),
child: Text(snapshot.data!.type!.toUpperCase(),
style: Theme.of(context)
.textTheme
.headline6
?.copyWith(color: Colors.white)),
style: chipTextVariant?.copyWith(
color: Colors.white)),
),
Text(
snapshot.data!.name!,
style: Theme.of(context).textTheme.headline2,
style: breakpoint.isSm
? textTheme.headline4
: textTheme.headline2,
),
Text(
"${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers",
style: Theme.of(context).textTheme.headline5,
style: breakpoint.isSm
? textTheme.bodyText1
: textTheme.headline5,
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// TODO: Implement check if user follows this artist
// LIMITATION: spotify-dart lib
@ -122,167 +148,170 @@ class _ArtistProfileState extends State<ArtistProfile> {
],
),
),
),
],
),
const SizedBox(height: 50),
FutureBuilder<Iterable<Track>>(
future:
spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
builder: (context, trackSnapshot) {
if (!trackSnapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
Playback playback = context.watch<Playback>();
var isPlaylistPlaying =
playback.currentPlaylist?.id == snapshot.data?.id;
playPlaylist(List<Track> tracks, {Track? currentTrack}) {
currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks,
id: snapshot.data!.id!,
name: "${snapshot.data!.name!} To Tracks",
thumbnail: imageToUrlString(snapshot.data?.images),
);
playback.setCurrentTrack = currentTrack;
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
],
),
const SizedBox(height: 50),
FutureBuilder<Iterable<Track>>(
future:
spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
builder: (context, trackSnapshot) {
if (!trackSnapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying =
playback.currentPlaylist?.id == snapshot.data?.id;
playPlaylist(List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks,
id: snapshot.data!.id!,
name: "${snapshot.data!.name!} To Tracks",
thumbnail: imageToUrlString(snapshot.data?.images),
);
playback.setCurrentTrack = currentTrack;
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
}
await playback.startPlaying();
}
}
return Column(children: [
Row(
children: [
Text(
"Top Tracks",
style: Theme.of(context).textTheme.headline4,
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(50),
return Column(children: [
Row(
children: [
Text(
"Top Tracks",
style: Theme.of(context).textTheme.headline4,
),
child: IconButton(
icon: Icon(isPlaylistPlaying
? Icons.stop_rounded
: Icons.play_arrow_rounded),
color: Colors.white,
onPressed: trackSnapshot.hasData
? () =>
playPlaylist(trackSnapshot.data!.toList())
: null,
),
)
],
),
...trackSnapshot.data
?.toList()
.asMap()
.entries
.map((track) {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
String? thumbnailUrl = imageToUrlString(
track.value.album?.images,
index:
(track.value.album?.images?.length ?? 1) -
1);
return TracksTableView.buildTrackTile(
context,
playback,
duration: duration,
track: track,
thumbnailUrl: thumbnailUrl,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist(
trackSnapshot.data!.toList(),
currentTrack: track.value,
Container(
margin: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(50),
),
);
}) ??
[],
]);
},
),
const SizedBox(height: 50),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Albums",
style: Theme.of(context).textTheme.headline4,
),
TextButton(
child: const Text("See All"),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ArtistAlbumView(
widget.artistId,
snapshot.data?.name ?? "KRTX",
child: IconButton(
icon: Icon(isPlaylistPlaying
? Icons.stop_rounded
: Icons.play_arrow_rounded),
color: Colors.white,
onPressed: trackSnapshot.hasData
? () => playPlaylist(
trackSnapshot.data!.toList())
: null,
),
)
],
),
...trackSnapshot.data
?.toList()
.asMap()
.entries
.map((track) {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
String? thumbnailUrl = imageToUrlString(
track.value.album?.images,
index:
(track.value.album?.images?.length ?? 1) -
1);
return TrackTile(
playback,
duration: duration,
track: track,
thumbnailUrl: thumbnailUrl,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist(
trackSnapshot.data!.toList(),
currentTrack: track.value,
),
);
}) ??
[],
]);
},
),
const SizedBox(height: 50),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Albums",
style: Theme.of(context).textTheme.headline4,
),
TextButton(
child: const Text("See All"),
onPressed: () {
GoRouter.of(context).push(
"/artist-album/$artistId",
extra: snapshot.data?.name ?? "KRTX",
);
},
)
],
),
const SizedBox(height: 10),
FutureBuilder<List<Album>>(
future: spotify.artists
.albums(snapshot.data!.id!)
.getPage(5, 0)
.then((al) => al.items?.toList() ?? []),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
return Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: snapshot.data
?.map((album) => AlbumCard(album))
.toList() ??
[],
),
));
},
)
],
),
const SizedBox(height: 10),
FutureBuilder<List<Album>>(
future: spotify.artists
.albums(snapshot.data!.id!)
.getPage(5, 0)
.then((al) => al.items?.toList() ?? []),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
return Center(
child: Wrap(
spacing: 20,
runSpacing: 20,
children: snapshot.data
?.map((album) => AlbumCard(album))
.toList() ??
[],
),
);
},
),
const SizedBox(height: 20),
Text(
"Fans also likes",
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 10),
FutureBuilder<Iterable<Artist>>(
future: spotify.artists.getRelatedArtists(widget.artistId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
),
);
},
),
const SizedBox(height: 20),
Text(
"Fans also likes",
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 10),
FutureBuilder<Iterable<Artist>>(
future: spotify.artists.getRelatedArtists(artistId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive());
}
return Center(
child: Wrap(
spacing: 20,
runSpacing: 20,
children: snapshot.data
?.map((artist) => ArtistCard(artist))
.toList() ??
[],
),
);
},
)
],
),
);
},
return Center(
child: Wrap(
spacing: 20,
runSpacing: 20,
children: snapshot.data
?.map((artist) => ArtistCard(artist))
.toList() ??
[],
),
);
},
)
],
),
);
},
),
),
);
}

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart' hide Page;
import 'package:provider/provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/components/Playlist/PlaylistGenreView.dart';
import 'package:spotube/hooks/usePagingController.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class CategoryCard extends StatefulWidget {
class CategoryCard extends HookWidget {
final Category category;
final Iterable<PlaylistSimple>? playlists;
const CategoryCard(
@ -14,11 +16,6 @@ class CategoryCard extends StatefulWidget {
this.playlists,
}) : super(key: key);
@override
_CategoryCardState createState() => _CategoryCardState();
}
class _CategoryCardState extends State<CategoryCard> {
@override
Widget build(BuildContext context) {
return Column(
@ -26,59 +23,81 @@ class _CategoryCardState extends State<CategoryCard> {
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.category.name ?? "Unknown",
category.name ?? "Unknown",
style: Theme.of(context).textTheme.headline5,
),
TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return PlaylistGenreView(
widget.category.id!,
widget.category.name!,
playlists: widget.playlists,
);
},
),
);
},
child: const Text("See all"),
)
],
),
),
Consumer<SpotifyDI>(
builder: (context, data, child) {
return FutureBuilder<Iterable<PlaylistSimple>>(
future: widget.playlists == null
? (widget.category.id != "user-featured-playlists"
? data.spotifyApi.playlists
.getByCategoryId(widget.category.id!)
: data.spotifyApi.playlists.featured)
.getPage(4, 0)
.then((value) => value.items ?? [])
: Future.value(widget.playlists),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Center(child: Text("Error occurred"));
HookConsumer(
builder: (context, ref, child) {
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
final scrollController = useScrollController();
final pagingController =
usePagingController<int, PlaylistSimple>(firstPageKey: 0);
final _error = useState(false);
final mounted = useIsMounted();
useEffect(() {
listener(pageKey) async {
try {
if (playlists != null &&
playlists?.isNotEmpty == true &&
mounted()) {
return pagingController.appendLastPage(playlists!.toList());
}
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
final Page<PlaylistSimple> page = await (category.id !=
"user-featured-playlists"
? spotifyApi.playlists.getByCategoryId(category.id!)
: spotifyApi.playlists.featured)
.getPage(3, pageKey);
if (!mounted()) return;
if (page.isLast && page.items != null) {
pagingController.appendLastPage(page.items!.toList());
} else if (page.items != null) {
pagingController.appendPage(
page.items!.toList(), page.nextOffset);
}
return Wrap(
spacing: 20,
runSpacing: 20,
children: snapshot.data!
.map((playlist) => PlaylistCard(playlist))
.toList(),
);
});
if (_error.value) _error.value = false;
} catch (e, stack) {
if (mounted()) {
if (!_error.value) _error.value = true;
pagingController.error = e;
}
print(
"[CategoryCard.pagingController.addPageRequestListener] $e");
print(stack);
}
}
pagingController.addPageRequestListener(listener);
return () {
pagingController.removePageRequestListener(listener);
};
}, [_error]);
if (_error.value) return const Text("Something Went Wrong");
return SizedBox(
height: 245,
child: Scrollbar(
controller: scrollController,
child: PagedListView<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_riverpod/flutter_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class UserArtists extends StatefulWidget {
class UserArtists extends ConsumerStatefulWidget {
const UserArtists({Key? key}) : super(key: key);
@override
State<UserArtists> createState() => _UserArtistsState();
ConsumerState<UserArtists> createState() => _UserArtistsState();
}
class _UserArtistsState extends State<UserArtists> {
class _UserArtistsState extends ConsumerState<UserArtists> {
final PagingController<String, Artist> _pagingController =
PagingController(firstPageKey: "");
@ -22,8 +22,8 @@ class _UserArtistsState extends State<UserArtists> {
WidgetsBinding.instance?.addPostFrameCallback((timestamp) {
_pagingController.addPageRequestListener((pageKey) async {
try {
SpotifyDI data = context.read<SpotifyDI>();
CursorPage<Artist> artists = await data.spotifyApi.me
SpotifyApi spotifyApi = ref.read(spotifyProvider);
CursorPage<Artist> artists = await spotifyApi.me
.following(FollowingType.artist)
.getPage(15, pageKey);
@ -51,10 +51,10 @@ class _UserArtistsState extends State<UserArtists> {
@override
Widget build(BuildContext context) {
SpotifyDI data = context.watch<SpotifyDI>();
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder<CursorPage<Artist>>(
future: data.spotifyApi.me.following(FollowingType.artist).first(),
future: spotifyApi.me.following(FollowingType.artist).first(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());

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/UserPlaylists.dart';
class UserLibrary extends StatefulWidget {
class UserLibrary extends StatelessWidget {
const UserLibrary({Key? key}) : super(key: key);
@override
_UserLibraryState createState() => _UserLibraryState();
}
class _UserLibraryState extends State<UserLibrary> {
@override
Widget build(BuildContext context) {
return Expanded(

View File

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

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
@ -8,127 +9,109 @@ import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/UserPreferences.dart';
class Login extends StatefulWidget {
class Login extends HookConsumerWidget {
const Login({Key? key}) : super(key: key);
@override
_LoginState createState() => _LoginState();
}
Widget build(BuildContext context, ref) {
var clientIdController = useTextEditingController();
var clientSecretController = useTextEditingController();
var accessTokenController = useTextEditingController();
var fieldError = useState(false);
class _LoginState extends State<Login> {
String clientId = "";
String clientSecret = "";
String accessToken = "";
bool _fieldError = false;
Future handleLogin(Auth authState) async {
try {
if (clientId == "" || clientSecret == "") {
return setState(() {
_fieldError = true;
});
}
await oauthLogin(context, clientId: clientId, clientSecret: clientSecret);
} catch (e) {
print("[Login.handleLogin] $e");
}
}
@override
Widget build(BuildContext context) {
return Consumer<Auth>(
builder: (context, authState, child) {
return Scaffold(
appBar: const PageWindowTitleBar(),
body: SingleChildScrollView(
child: Center(
child: Column(
children: [
Image.asset(
"assets/spotube-logo.png",
width: 400,
height: 400,
),
Text("Add your spotify credentials to get started",
style: Theme.of(context).textTheme.headline4),
const Text(
"Don't worry, any of your credentials won't be collected or shared with anyone"),
const Hyperlink("How to get these client-id & client-secret?",
"https://github.com/KRTirtho/spotube#configuration"),
const SizedBox(
height: 10,
),
Container(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: Column(
children: [
TextField(
decoration: const InputDecoration(
hintText: "Spotify Client ID",
label: Text("ClientID"),
),
onChanged: (value) {
setState(() {
clientId = value;
});
},
),
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
hintText: "Spotify Client Secret",
label: Text("Client Secret"),
),
onChanged: (value) {
setState(() {
clientSecret = value;
});
},
),
const SizedBox(height: 10),
const Divider(color: Colors.grey),
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
label: Text("Genius Access Token (optional)"),
),
onChanged: (value) {
setState(() {
accessToken = value;
});
},
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: () async {
await handleLogin(authState);
UserPreferences preferences =
context.read<UserPreferences>();
SharedPreferences localStorage =
await SharedPreferences.getInstance();
preferences.setGeniusAccessToken(accessToken);
await localStorage.setString(
LocalStorageKeys.geniusAccessToken,
accessToken);
setState(() {
accessToken = "";
});
},
child: const Text("Submit"),
)
],
),
),
],
),
),
),
Future handleLogin(Auth authState) async {
try {
if (clientIdController.value.text == "" ||
clientSecretController.value.text == "") {
fieldError.value = true;
}
await oauthLogin(
ref.read(authProvider),
clientId: clientIdController.value.text,
clientSecret: clientSecretController.value.text,
);
},
} catch (e) {
print("[Login.handleLogin] $e");
}
}
Auth authState = ref.watch(authProvider);
return Scaffold(
appBar: const PageWindowTitleBar(),
body: SingleChildScrollView(
child: Center(
child: Column(
children: [
Image.asset(
"assets/spotube-logo.png",
width: 400,
height: 400,
),
Text("Add your spotify credentials to get started",
style: Theme.of(context).textTheme.headline4),
const Text(
"Don't worry, any of your credentials won't be collected or shared with anyone"),
const Hyperlink("How to get these client-id & client-secret?",
"https://github.com/KRTirtho/spotube#configuration"),
const SizedBox(
height: 10,
),
Container(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: Column(
children: [
TextField(
controller: clientIdController,
decoration: const InputDecoration(
hintText: "Spotify Client ID",
label: Text("ClientID"),
),
),
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
hintText: "Spotify Client Secret",
label: Text("Client Secret"),
),
controller: clientSecretController,
),
const SizedBox(height: 10),
const Divider(color: Colors.grey),
const SizedBox(height: 10),
TextField(
decoration: const InputDecoration(
label: Text("Genius Access Token (optional)"),
),
controller: accessTokenController,
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: () async {
await handleLogin(authState);
UserPreferences preferences =
ref.read(userPreferencesProvider);
SharedPreferences localStorage =
await SharedPreferences.getInstance();
preferences.setGeniusAccessToken(
accessTokenController.value.text);
await localStorage.setString(
LocalStorageKeys.geniusAccessToken,
accessTokenController.value.text);
accessTokenController.text = "";
},
child: const Text("Submit"),
)
],
),
),
],
),
),
),
);
}
}

View File

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

View File

@ -1,388 +1,185 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:spotify/spotify.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart' hide Image;
import 'package:spotube/components/Player/PlayerOverlay.dart';
import 'package:spotube/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
import 'package:spotube/components/Player/PlayerControls.dart';
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/search-youtube.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class Player extends StatefulWidget {
class Player extends HookConsumerWidget {
const Player({Key? key}) : super(key: key);
@override
_PlayerState createState() => _PlayerState();
}
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
class _PlayerState extends State<Player> with WidgetsBindingObserver {
late AudioPlayer player;
bool _isPlaying = false;
bool _shuffled = false;
Duration? _duration;
final _volume = useState(0.0);
String? _currentTrackId;
final breakpoint = useBreakpoints();
double _volume = 0;
final AudioPlayer player = playback.player;
late YoutubeExplode youtube;
final Future<SharedPreferences> future =
useMemoized(SharedPreferences.getInstance);
final AsyncSnapshot<SharedPreferences?> localStorage =
useFuture(future, initialData: null);
@override
void initState() {
try {
super.initState();
player = AudioPlayer();
youtube = YoutubeExplode();
useEffect(() {
/// warm up the audio player before playing actual audio
/// It's for resolving unresolved issue related to just_audio's
/// [disposeAllPlayers] method which is throwing
/// [UnimplementedException] in the [PlatformInterface]
/// implementation
player.setAsset("assets/warmer.mp3");
return null;
}, []);
WidgetsBinding.instance?.addObserver(this);
WidgetsBinding.instance?.addPostFrameCallback(_init);
} catch (e, stack) {
print("[Player.initState()] $e");
print(stack);
}
}
useEffect(() {
if (localStorage.hasData) {
_volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
player.volume;
}
return null;
}, [localStorage.data]);
_init(Duration timeStamp) async {
try {
setState(() {
_volume = player.volume;
});
player.playingStream.listen((playing) async {
setState(() {
_isPlaying = playing;
});
});
String albumArt = useMemoized(
() => imageToUrlString(
playback.currentTrack?.album?.images,
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
),
[playback.currentTrack?.album?.images],
);
player.durationStream.listen((duration) async {
if (duration != null) {
// Actually things doesn't work all the time as they were
// described. So instead of listening to a `playback.ready`
// stream, it has to listen to duration stream since duration
// is always added to the Stream sink after all icyMetadata has
// been loaded thus indicating buffering started
if (duration != Duration.zero && duration != _duration) {
// this line is for prev/next or already playing playlist
if (player.playing) await player.pause();
await player.play();
}
setState(() {
_duration = duration;
});
}
});
final entryRef = useRef<OverlayEntry?>(null);
player.processingStateStream.listen((event) async {
try {
if (event == ProcessingState.completed && _currentTrackId != null) {
_movePlaylistPositionBy(1);
}
} catch (e, stack) {
print("[PrecessingStateStreamListener] $e");
disposeOverlay() {
try {
entryRef.value?.remove();
entryRef.value = null;
} catch (e, stack) {
if (e is! AssertionError) {
print("[Player.useEffect.cleanup] $e");
print(stack);
}
});
} catch (e) {
print("[Player._init()]: $e");
}
}
}
@override
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
player.dispose();
youtube.close();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// Release the player's resources when not in use. We use "stop" so that
// if the app resumes later, it will still remember what position to
// resume from.
player.stop();
}
}
void _movePlaylistPositionBy(int pos) {
Playback playback = context.read<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;
useEffect(() {
// clearing the overlay-entry as passing the already available
// entry will result in splashing while resizing the window
if (entryRef.value != null) disposeOverlay();
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
entryRef.value = OverlayEntry(
opaque: false,
builder: (context) => PlayerOverlay(albumArt: albumArt),
);
// I can't believe useEffect doesn't run Post Frame aka
// after rendering/painting the UI
// `My disappointment is immeasurable and my day is ruined` XD
WidgetsBinding.instance?.addPostFrameCallback((time) {
Overlay.of(context)?.insert(entryRef.value!);
});
}
}
}
return () {
disposeOverlay();
};
}, [breakpoint]);
Future _playTrack(Track currentTrack, Playback playback) async {
try {
if (currentTrack.id != _currentTrackId) {
if (currentTrack.uri != null) {
await player
.setAudioSource(
AudioSource.uri(Uri.parse(currentTrack.uri!)),
preload: true,
)
.then((value) async {
setState(() {
_currentTrackId = currentTrack.id;
if (_duration != null) {
_duration = value;
}
});
});
}
var ytTrack = await toYoutubeTrack(youtube, currentTrack);
if (playback.setTrackUriById(currentTrack.id!, ytTrack.uri!)) {
await player
.setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!)))
.then((value) {
setState(() {
_currentTrackId = currentTrack.id;
});
});
}
}
} catch (e, stack) {
print("[Player._playTrack()] $e");
print(stack);
// returning an empty non spacious Container as the overlay will take
// place in the global overlay stack aka [_entries]
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
return Container();
}
}
_onNext() async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(1);
print("ON NEXT");
} catch (e, stack) {
print("[PlayerControls.onNext()] $e");
print(stack);
}
}
_onPrevious() async {
try {
await player.pause();
await player.seek(Duration.zero);
_movePlaylistPositionBy(-1);
} catch (e, stack) {
print("[PlayerControls.onPrevious()] $e");
print(stack);
}
}
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).backgroundColor,
child: Consumer<Playback>(
builder: (context, playback, widget) {
if (playback.currentPlaylist != null &&
playback.currentTrack != null) {
_playTrack(playback.currentTrack!, playback);
}
String? albumArt = imageToUrlString(
playback.currentTrack?.album?.images,
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
);
return Material(
type: MaterialType.transparency,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (albumArt != null)
CachedNetworkImage(
imageUrl: albumArt,
maxHeightDiskCache: 50,
maxWidthDiskCache: 50,
placeholder: (context, url) {
return Container(
height: 50,
width: 50,
color: Colors.green[400],
);
},
),
// title of the currently playing track
Flexible(
flex: 1,
child: Column(
children: [
Text(
playback.currentTrack?.name ?? "Not playing",
style: const TextStyle(fontWeight: FontWeight.bold),
),
artistsToClickableArtists(
playback.currentTrack?.artists ?? [],
mainAxisAlignment: MainAxisAlignment.center,
)
],
),
),
// controls
Flexible(
flex: 3,
child: PlayerControls(
positionStream: player.positionStream,
isPlaying: _isPlaying,
duration: _duration ?? Duration.zero,
shuffled: _shuffled,
onNext: _onNext,
onPrevious: _onPrevious,
onPause: () async {
try {
await player.pause();
} catch (e, stack) {
print("[PlayerControls.onPause()] $e");
print(stack);
}
},
onPlay: () async {
try {
await player.play();
} catch (e, stack) {
print("[PlayerControls.onPlay()] $e");
print(stack);
}
},
onSeek: (value) async {
try {
await player.seek(Duration(seconds: value.toInt()));
} catch (e, stack) {
print("[PlayerControls.onSeek()] $e");
print(stack);
}
},
onShuffle: () async {
if (playback.currentTrack == null ||
playback.currentPlaylist == null) return;
try {
if (!_shuffled) {
playback.currentPlaylist!.shuffle();
setState(() {
_shuffled = true;
});
} else {
playback.currentPlaylist!.unshuffle();
setState(() {
_shuffled = false;
});
}
} catch (e, stack) {
print("[PlayerControls.onShuffle()] $e");
print(stack);
}
},
onStop: () async {
try {
await player.pause();
await player.seek(Duration.zero);
setState(() {
_isPlaying = false;
_currentTrackId = null;
_duration = null;
_shuffled = false;
});
playback.reset();
} catch (e, stack) {
print("[PlayerControls.onStop()] $e");
print(stack);
}
},
),
),
// add to saved tracks
Expanded(
flex: 1,
child: Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
Container(
height: 20,
constraints: const BoxConstraints(maxWidth: 200),
child: Slider.adaptive(
value: _volume,
onChanged: (value) async {
try {
await player.setVolume(value).then((_) {
setState(() {
_volume = value;
});
});
} catch (e, stack) {
print("[VolumeSlider.onChange()] $e");
print(stack);
}
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DownloadTrackButton(
track: playback.currentTrack,
),
Consumer<SpotifyDI>(builder: (context, data, widget) {
return FutureBuilder<bool>(
future: playback.currentTrack?.id != null
? data.spotifyApi.tracks.me
.containsOne(playback.currentTrack!.id!)
: Future.value(false),
initialData: false,
builder: (context, snapshot) {
bool isLiked = snapshot.data ?? false;
return IconButton(
icon: Icon(
!isLiked
? Icons.favorite_outline_rounded
: Icons.favorite_rounded,
color: isLiked ? Colors.green : null,
),
onPressed: () {
if (!isLiked &&
playback.currentTrack?.id != null) {
data.spotifyApi.tracks.me
.saveOne(
playback.currentTrack!.id!)
.then((value) => setState(() {}));
}
});
});
}),
],
),
],
),
)
],
child: Material(
type: MaterialType.transparency,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
// controls
const Expanded(
flex: 3,
child: PlayerControls(),
),
);
},
// add to saved tracks
Expanded(
flex: 1,
child: Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
Container(
height: 20,
constraints: const BoxConstraints(maxWidth: 200),
child: Slider.adaptive(
value: _volume.value,
onChanged: (value) async {
try {
await player.setVolume(value).then((_) {
_volume.value = value;
localStorage.data?.setDouble(
LocalStorageKeys.volume,
value,
);
});
} catch (e, stack) {
print("[VolumeSlider.onChange()] $e");
print(stack);
}
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DownloadTrackButton(
track: playback.currentTrack,
),
Consumer(builder: (context, ref, widget) {
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder<bool>(
future: playback.currentTrack?.id != null
? spotifyApi.tracks.me
.containsOne(playback.currentTrack!.id!)
: Future.value(false),
initialData: false,
builder: (context, snapshot) {
bool isLiked = snapshot.data ?? false;
return IconButton(
icon: Icon(
!isLiked
? Icons.favorite_outline_rounded
: Icons.favorite_rounded,
color: isLiked ? Colors.green : null,
),
onPressed: () {
if (!isLiked &&
playback.currentTrack?.id != null) {
spotifyApi.tracks.me
.saveOne(playback.currentTrack!.id!);
}
});
});
}),
],
),
],
),
)
],
),
),
);
}

View File

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

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

View File

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

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

View File

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

View File

@ -1,213 +1,202 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/main.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/UserPreferences.dart';
class Settings extends StatefulWidget {
class Settings extends HookConsumerWidget {
const Settings({Key? key}) : super(key: key);
@override
_SettingsState createState() => _SettingsState();
}
Widget build(BuildContext context, ref) {
UserPreferences preferences = ref.watch(userPreferencesProvider);
ThemeMode theme = ref.watch(themeProvider);
var geniusAccessToken = useState<String?>(null);
TextEditingController textEditingController = useTextEditingController();
class _SettingsState extends State<Settings> {
TextEditingController? _textEditingController;
String? _geniusAccessToken;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
_textEditingController?.addListener(() {
setState(() {
_geniusAccessToken = _textEditingController?.value.text;
});
textEditingController.addListener(() {
geniusAccessToken.value = textEditingController.value.text;
});
}
@override
void dispose() {
_textEditingController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
UserPreferences preferences = context.watch<UserPreferences>();
return Scaffold(
appBar: PageWindowTitleBar(
leading: const BackButton(),
center: Text(
"Settings",
style: Theme.of(context).textTheme.headline5,
return SafeArea(
child: Scaffold(
appBar: PageWindowTitleBar(
leading: const BackButton(),
center: Text(
"Settings",
style: Theme.of(context).textTheme.headline5,
),
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 2,
child: Text(
"Genius Access Token",
style: Theme.of(context).textTheme.subtitle1,
),
),
Expanded(
flex: 1,
child: TextField(
controller: _textEditingController,
decoration: InputDecoration(
hintText: preferences.geniusAccessToken,
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 2,
child: Text(
"Genius Access Token",
style: Theme.of(context).textTheme.subtitle1,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: _geniusAccessToken != null
? () async {
SharedPreferences localStorage =
await SharedPreferences.getInstance();
preferences
.setGeniusAccessToken(_geniusAccessToken);
localStorage.setString(
LocalStorageKeys.geniusAccessToken,
_geniusAccessToken!);
setState(() {
_geniusAccessToken = null;
});
_textEditingController?.text = "";
}
: null,
child: const Text("Save"),
),
)
],
),
const SizedBox(height: 10),
SettingsHotKeyTile(
title: "Next track global shortcut",
currentHotKey: preferences.nextTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setNextTrackHotKey(value);
},
),
SettingsHotKeyTile(
title: "Prev track global shortcut",
currentHotKey: preferences.prevTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setPrevTrackHotKey(value);
},
),
SettingsHotKeyTile(
title: "Play/Pause global shortcut",
currentHotKey: preferences.playPauseHotKey,
onHotKeyRecorded: (value) {
preferences.setPlayPauseHotKey(value);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Theme"),
DropdownButton<ThemeMode>(
value: MyApp.of(context)?.getThemeMode(),
items: const [
DropdownMenuItem(
child: Text(
"Dark",
Expanded(
flex: 1,
child: TextField(
controller: textEditingController,
decoration: InputDecoration(
hintText: preferences.geniusAccessToken,
),
value: ThemeMode.dark,
),
DropdownMenuItem(
child: Text(
"Light",
),
value: ThemeMode.light,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: geniusAccessToken.value != null
? () async {
SharedPreferences localStorage =
await SharedPreferences.getInstance();
preferences.setGeniusAccessToken(
geniusAccessToken.value);
localStorage.setString(
LocalStorageKeys.geniusAccessToken,
geniusAccessToken.value ?? "");
geniusAccessToken.value = null;
textEditingController.text = "";
}
: null,
child: const Text("Save"),
),
DropdownMenuItem(
child: Text("System"),
value: ThemeMode.system,
),
],
onChanged: (value) {
if (value != null) {
MyApp.of(context)?.setThemeMode(value);
}
)
],
),
const SizedBox(height: 10),
if (!Platform.isAndroid && !Platform.isIOS) ...[
SettingsHotKeyTile(
title: "Next track global shortcut",
currentHotKey: preferences.nextTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setNextTrackHotKey(value);
},
)
),
SettingsHotKeyTile(
title: "Prev track global shortcut",
currentHotKey: preferences.prevTrackHotKey,
onHotKeyRecorded: (value) {
preferences.setPrevTrackHotKey(value);
},
),
SettingsHotKeyTile(
title: "Play/Pause global shortcut",
currentHotKey: preferences.playPauseHotKey,
onHotKeyRecorded: (value) {
preferences.setPlayPauseHotKey(value);
},
),
],
),
const SizedBox(height: 10),
Builder(builder: (context) {
var auth = context.read<Auth>();
return Row(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Log out of this account"),
ElevatedButton(
child: const Text("Logout"),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.red),
),
onPressed: () async {
SharedPreferences localStorage =
await SharedPreferences.getInstance();
await localStorage.clear();
auth.logout();
Navigator.of(context).pop();
const Text("Theme"),
DropdownButton<ThemeMode>(
value: theme,
items: const [
DropdownMenuItem(
child: Text(
"Dark",
),
value: ThemeMode.dark,
),
DropdownMenuItem(
child: Text(
"Light",
),
value: ThemeMode.light,
),
DropdownMenuItem(
child: Text("System"),
value: ThemeMode.system,
),
],
onChanged: (value) {
if (value != null) {
ref.read(themeProvider.notifier).state = value;
}
},
)
],
),
const SizedBox(height: 10),
Builder(builder: (context) {
Auth auth = ref.watch(authProvider);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Log out of this account"),
ElevatedButton(
child: const Text("Logout"),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.red),
),
onPressed: () async {
SharedPreferences localStorage =
await SharedPreferences.getInstance();
await localStorage.clear();
auth.logout();
GoRouter.of(context).pop();
},
),
],
);
}),
const SizedBox(height: 40),
const Text("Spotube v1.2.0"),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text("Author: "),
Hyperlink(
"Kingkor Roy Tirtho",
"https://github.com/KRTirtho",
),
],
);
}),
const SizedBox(height: 40),
const Text("Spotube v1.2.0"),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text("Author: "),
Hyperlink(
"Kingkor Roy Tirtho",
"https://github.com/KRTirtho",
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Hyperlink(
"💚 Sponsor/Donate 💚",
"https://opencollective.com/spotube",
),
Text(""),
Hyperlink(
"BSD-4-Clause LICENSE",
"https://github.com/KRTirtho/spotube/blob/master/LICENSE",
),
Text(""),
Hyperlink(
"Bug Report",
"https://github.com/KRTirtho/spotube/issues/new?assignees=&labels=bug&template=bug_report.md&title=",
),
],
),
const SizedBox(height: 10),
const Text("© Spotube 2022. All rights reserved")
],
),
const SizedBox(height: 20),
Wrap(
alignment: WrapAlignment.center,
children: const [
Hyperlink(
"💚 Sponsor/Donate 💚",
"https://opencollective.com/spotube",
),
Text(""),
Hyperlink(
"BSD-4-Clause LICENSE",
"https://github.com/KRTirtho/spotube/blob/master/LICENSE",
),
Text(""),
Hyperlink(
"Bug Report",
"https://github.com/KRTirtho/spotube/issues/new?assignees=&labels=bug&template=bug_report.md&title=",
),
],
),
const SizedBox(height: 10),
const Text("© Spotube 2022. All rights reserved")
],
),
),
),
);

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl;
final bool isPlaying;
final String title;
@ -12,6 +13,7 @@ class PlaybuttonCard extends StatelessWidget {
required this.imageUrl,
required this.isPlaying,
required this.title,
this.margin,
this.description,
this.onPlaybuttonPressed,
this.onTap,
@ -20,88 +22,92 @@ class PlaybuttonCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Ink(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
blurRadius: 10,
offset: const Offset(0, 3),
spreadRadius: 5,
color: Theme.of(context).shadowColor)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// thumbnail of the playlist
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: imageUrl,
progressIndicatorBuilder: (context, url, progress) {
return CircularProgressIndicator.adaptive(
value: progress.progress,
);
},
),
),
Positioned.directional(
textDirection: TextDirection.ltr,
bottom: 10,
end: 5,
child: Builder(builder: (context) {
return ElevatedButton(
onPressed: onPlaybuttonPressed,
child: Icon(
isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
style: ButtonStyle(
shape: MaterialStateProperty.all(
const CircleBorder(),
),
padding: MaterialStateProperty.all(
const EdgeInsets.all(16),
),
),
);
}),
)
],
),
const SizedBox(height: 5),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
return Container(
margin: margin,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Ink(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
blurRadius: 10,
offset: const Offset(0, 3),
spreadRadius: 5,
color: Theme.of(context).shadowColor)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// thumbnail of the playlist
Stack(
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) =>
Image.asset("assets/placeholder.png"),
),
),
if (description != null) ...[
const SizedBox(height: 10),
Text(
description!,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.headline4?.color,
),
)
]
Positioned.directional(
textDirection: TextDirection.ltr,
bottom: 10,
end: 5,
child: Builder(builder: (context) {
return ElevatedButton(
onPressed: onPlaybuttonPressed,
child: Icon(
isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
style: ButtonStyle(
shape: MaterialStateProperty.all(
const CircleBorder(),
),
padding: MaterialStateProperty.all(
const EdgeInsets.all(16),
),
),
);
}),
)
],
),
)
],
const SizedBox(height: 5),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
children: [
Tooltip(
message: title,
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
if (description != null) ...[
const SizedBox(height: 10),
Text(
description!,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.headline4?.color,
),
)
]
],
),
)
],
),
),
),
),

View File

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

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:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumView.dart';
import 'package:spotube/components/Shared/LinkText.dart';
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart';
class TracksTableView extends StatelessWidget {
class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks;
const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed})
: 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
Widget build(BuildContext context) {
Playback playback = context.watch<Playback>();
Widget build(context, ref) {
Playback playback = ref.watch(playbackProvider);
TextStyle tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final breakpoint = useBreakpoints();
return Expanded(
child: Scrollbar(
child: ListView(
@ -127,21 +52,25 @@ class TracksTableView extends StatelessWidget {
),
),
// used alignment of this table-head
const SizedBox(width: 100),
Expanded(
child: Row(
children: [
Text(
"Album",
overflow: TextOverflow.ellipsis,
style: tableHeadStyle,
),
],
),
),
const SizedBox(width: 10),
Text("Time", style: tableHeadStyle),
const SizedBox(width: 10),
if (breakpoint.isMoreThan(Breakpoints.md)) ...[
const SizedBox(width: 100),
Expanded(
child: Row(
children: [
Text(
"Album",
overflow: TextOverflow.ellipsis,
style: tableHeadStyle,
),
],
),
)
],
if (!breakpoint.isSm) ...[
const SizedBox(width: 10),
Text("Time", style: tableHeadStyle),
const SizedBox(width: 10),
]
],
),
...tracks.asMap().entries.map((track) {
@ -151,11 +80,13 @@ class TracksTableView extends StatelessWidget {
);
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return buildTrackTile(context, playback,
track: track,
duration: duration,
thumbnailUrl: thumbnailUrl,
onTrackPlayButtonPressed: onTrackPlayButtonPressed);
return TrackTile(
playback,
track: track,
duration: duration,
thumbnailUrl: thumbnailUrl,
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
);
}).toList()
],
),
@ -163,3 +94,103 @@ class TracksTableView extends StatelessWidget {
);
}
}
class TrackTile extends HookWidget {
final Playback playback;
final MapEntry<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:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistProfile.dart';
import 'package:spotube/components/Shared/LinkText.dart';
Widget artistsToClickableArtists(
List<ArtistSimple> artists, {
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
WrapAlignment mainAxisAlignment = WrapAlignment.center,
TextStyle textStyle = const TextStyle(),
}) {
return Row(
return Wrap(
crossAxisAlignment: crossAxisAlignment,
mainAxisAlignment: mainAxisAlignment,
alignment: mainAxisAlignment,
children: artists
.asMap()
.entries
@ -19,10 +19,9 @@ Widget artistsToClickableArtists(
(artist.key != artists.length - 1)
? "${artist.value.name}, "
: artist.value.name!,
MaterialPageRoute<ArtistProfile>(
builder: (context) => ArtistProfile(artist.value.id!),
),
"/artist/${artist.value.id}",
overflow: TextOverflow.ellipsis,
style: textStyle,
),
)
.toList(),

View File

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

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

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:spotify/spotify.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -21,7 +23,16 @@ Future<Track> toYoutubeTrack(YoutubeExplode youtube, Track track) async {
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
track.uri = trackManifest.audioOnly.withHighestBitrate().url.toString();
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
// codec/mimetype for those Platforms
track.uri = (Platform.isMacOS || Platform.isIOS
? trackManifest.audioOnly
.where((info) => info.codec.mimeType == "audio/mp4")
.withHighestBitrate()
: trackManifest.audioOnly.withHighestBitrate())
.url
.toString();
track.href = ytVideo.url;
return track;
}

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,210 +1,163 @@
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Home.dart';
import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/YouTube.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await hotKeyManager.unregisterAll();
runApp(MyApp());
doWhenWindowReady(() {
appWindow.minSize = const Size(900, 700);
appWindow.size = const Size(900, 700);
appWindow.alignment = Alignment.center;
appWindow.maximize();
appWindow.show();
});
if (!Platform.isAndroid && !Platform.isIOS) {
WidgetsFlutterBinding.ensureInitialized();
await hotKeyManager.unregisterAll();
doWhenWindowReady(() {
appWindow.minSize =
Size(Platform.isAndroid || Platform.isIOS ? 280 : 359, 700);
appWindow.size = const Size(900, 700);
appWindow.alignment = Alignment.center;
appWindow.maximize();
appWindow.show();
});
}
runApp(ProviderScope(child: MyApp()));
}
class MyApp extends StatefulWidget {
static _MyAppState? of(BuildContext context) =>
context.findAncestorStateOfType<_MyAppState>();
class MyApp extends HookConsumerWidget {
final GoRouter _router = createGoRouter();
MyApp({Key? key}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
Widget build(BuildContext context, ref) {
var themeMode = ref.watch(themeProvider);
var player = ref.watch(audioPlayerProvider);
var youtube = ref.watch(youtubeProvider);
useEffect(() {
SharedPreferences.getInstance().then((localStorage) {
String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
var themeNotifier = ref.read(themeProvider.notifier);
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.system;
@override
void initState() {
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
SharedPreferences localStorage = await SharedPreferences.getInstance();
String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
setState(() {
switch (themeMode) {
case "light":
_themeMode = ThemeMode.light;
themeNotifier.state = ThemeMode.light;
break;
case "dark":
_themeMode = ThemeMode.dark;
themeNotifier.state = ThemeMode.dark;
break;
default:
_themeMode = ThemeMode.system;
themeNotifier.state = ThemeMode.system;
}
});
});
super.initState();
}
return () {
player.dispose();
youtube.close();
};
}, []);
void setThemeMode(ThemeMode themeMode) {
SharedPreferences.getInstance().then((localStorage) {
localStorage.setString(
LocalStorageKeys.themeMode, themeMode.toString().split(".").last);
setState(() {
_themeMode = themeMode;
});
});
}
ThemeMode getThemeMode() {
return _themeMode;
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<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();
},
return MaterialApp.router(
routeInformationParser: _router.routeInformationParser,
routerDelegate: _router.routerDelegate,
debugShowCheckedModeBanner: false,
title: 'Spotube',
theme: ThemeData(
primaryColor: Colors.green,
primarySwatch: Colors.green,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Spotube',
theme: ThemeData(
primaryColor: Colors.green,
primarySwatch: Colors.green,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
),
shadowColor: Colors.grey[300],
backgroundColor: Colors.white,
textTheme: TextTheme(
bodyText1: TextStyle(color: Colors.grey[850]),
headline1: TextStyle(color: Colors.grey[850]),
headline2: TextStyle(color: Colors.grey[850]),
headline3: TextStyle(color: Colors.grey[850]),
headline4: TextStyle(color: Colors.grey[850]),
headline5: TextStyle(color: Colors.grey[850]),
headline6: TextStyle(color: Colors.grey[850]),
),
listTileTheme: ListTileThemeData(
iconColor: Colors.grey[850],
horizontalTitleGap: 0,
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.green[400]!,
width: 2.0,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey[800]!,
),
shadowColor: Colors.grey[300],
backgroundColor: Colors.white,
textTheme: TextTheme(
bodyText1: TextStyle(color: Colors.grey[850]),
headline1: TextStyle(color: Colors.grey[850]),
headline2: TextStyle(color: Colors.grey[850]),
headline3: TextStyle(color: Colors.grey[850]),
headline4: TextStyle(color: Colors.grey[850]),
headline5: TextStyle(color: Colors.grey[850]),
headline6: TextStyle(color: Colors.grey[850]),
),
listTileTheme: ListTileThemeData(
iconColor: Colors.grey[850],
horizontalTitleGap: 0,
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.green[400]!,
width: 2.0,
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: Colors.blueGrey[50],
unselectedIconTheme:
IconThemeData(color: Colors.grey[850], opacity: 1),
unselectedLabelTextStyle: TextStyle(
color: Colors.grey[850],
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey[800]!,
),
),
cardTheme: CardTheme(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
color: Colors.white,
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.green,
primarySwatch: Colors.green,
backgroundColor: Colors.blueGrey[900],
scaffoldBackgroundColor: Colors.blueGrey[900],
dialogBackgroundColor: Colors.blueGrey[800],
shadowColor: Colors.black26,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
navigationRailTheme: NavigationRailThemeData(
backgroundColor: Colors.blueGrey[50],
unselectedIconTheme:
IconThemeData(color: Colors.grey[850], opacity: 1),
unselectedLabelTextStyle: TextStyle(
color: Colors.grey[850],
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.green[400]!,
width: 2.0,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey[800]!,
),
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: Colors.blueGrey[800],
unselectedIconTheme: const IconThemeData(opacity: 1),
),
cardTheme: CardTheme(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
color: Colors.blueGrey[900],
elevation: 20,
),
canvasColor: Colors.blueGrey[900],
),
themeMode: _themeMode,
home: const Home(),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.blueGrey[50],
height: 55,
),
cardTheme: CardTheme(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
color: Colors.white,
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.green,
primarySwatch: Colors.green,
backgroundColor: Colors.blueGrey[900],
scaffoldBackgroundColor: Colors.blueGrey[900],
dialogBackgroundColor: Colors.blueGrey[800],
shadowColor: Colors.black26,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.green[400]!,
width: 2.0,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey[800]!,
),
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: Colors.blueGrey[800],
unselectedIconTheme: const IconThemeData(opacity: 1),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.blueGrey[800],
height: 55,
),
cardTheme: CardTheme(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
color: Colors.blueGrey[900],
elevation: 20,
),
canvasColor: Colors.blueGrey[900],
),
themeMode: themeMode,
);
}
}

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 prevTrackHotKey = "prev_track_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_riverpod/flutter_riverpod.dart';
class Auth with ChangeNotifier {
String? _clientId;
@ -51,4 +52,11 @@ class Auth with ChangeNotifier {
_isLoggedIn = false;
notifyListeners();
}
@override
String toString() {
return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
}
}
var authProvider = ChangeNotifierProvider<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_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/helpers/search-youtube.dart';
import 'package:spotube/provider/AudioPlayer.dart';
import 'package:spotube/provider/YouTube.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class CurrentPlaylist {
List<Track>? _tempTrack;
@ -7,6 +16,7 @@ class CurrentPlaylist {
String id;
String name;
String thumbnail;
CurrentPlaylist({
required this.tracks,
required this.id,
@ -36,13 +46,105 @@ class CurrentPlaylist {
class Playback extends ChangeNotifier {
CurrentPlaylist? _currentPlaylist;
Track? _currentTrack;
Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) {
_currentPlaylist = currentPlaylist;
_currentTrack = currentTrack;
// states
bool _isPlaying = false;
Duration? _duration;
// using custom listeners for duration as it changes super quickly
// which will cause re-renders in components that don't even need it
// thus only allowing to listen to change in duration through only
// a listener function
List<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;
Track? get currentTrack => _currentTrack;
bool get isPlaying => _isPlaying;
/// this duration field is almost static & changes occasionally
///
/// If you want realtime duration with state-update/re-render
/// use custom state & the [addDurationChangeListener] function to do so
Duration? get duration => _duration;
_callAllDurationListeners(Duration? arg) {
for (var listener in _durationListeners) {
listener(arg);
}
}
void addDurationChangeListener(void Function(Duration? duration) listener) {
_durationListeners.add(listener);
}
void removeDurationChangeListener(
void Function(Duration? duration) listener) {
_durationListeners =
_durationListeners.where((p) => p != listener).toList();
}
set setCurrentTrack(Track track) {
_currentTrack = track;
@ -54,7 +156,10 @@ class Playback extends ChangeNotifier {
notifyListeners();
}
reset() {
void reset() {
_isPlaying = false;
_duration = null;
_callAllDurationListeners(null);
_currentPlaylist = null;
_currentTrack = null;
notifyListeners();
@ -75,6 +180,82 @@ class Playback extends ChangeNotifier {
return false;
}
}
@override
dispose() {
_processingStateStreamListener?.cancel();
_durationStreamListener?.cancel();
_playingStreamListener?.cancel();
_audioInterruptionEventListener?.cancel();
_audioSession?.setActive(false);
super.dispose();
}
movePlaylistPositionBy(int pos) {
if (_currentTrack != null && _currentPlaylist != null) {
int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos;
var safeIndex = index > _currentPlaylist!.trackIds.length - 1
? 0
: index < 0
? _currentPlaylist!.trackIds.length
: index;
Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex)
? _currentPlaylist!.tracks.elementAt(safeIndex)
: null;
if (track != null) {
_duration = null;
_callAllDurationListeners(null);
_currentTrack = track;
notifyListeners();
// starts to play the newly entered next/prev track
startPlaying();
}
}
}
Future<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);
}
}
}
var x = Playback();
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:spotube/components/Home/Home.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
class SpotifyDI with ChangeNotifier {
SpotifyApi _spotifyApi;
var spotifyProvider = Provider<SpotifyApi>((ref) {
Auth authState = ref.watch(authProvider);
SpotifyDI(this._spotifyApi);
SpotifyApi get spotifyApi => _spotifyApi;
}
return SpotifyApi(
SpotifyApiCredentials(
authState.clientId,
authState.clientSecret,
accessToken: authState.accessToken,
refreshToken: authState.refreshToken,
expiration: authState.expiration,
scopes: spotifyScopes,
),
onCredentialsRefreshed: (credentials) async {
SharedPreferences localStorage = await SharedPreferences.getInstance();
localStorage.setString(
LocalStorageKeys.refreshToken,
credentials.refreshToken!,
);
localStorage.setString(
LocalStorageKeys.accessToken,
credentials.accessToken!,
);
localStorage.setString(LocalStorageKeys.clientId, credentials.clientId!);
localStorage.setString(
LocalStorageKeys.clientSecret,
credentials.clientSecret!,
);
},
);
});

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

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

View File

@ -37,7 +37,6 @@ dependencies:
cached_network_image: ^3.2.0
html: ^0.15.0
http: ^0.13.4
provider: ^6.0.1
shared_preferences: ^2.0.11
spotify: ^0.6.0
url_launcher: ^6.0.17
@ -49,6 +48,13 @@ dependencies:
just_audio_libwinmedia: ^0.0.4
path: ^1.8.0
path_provider: ^2.0.8
collection: ^1.15.0
flutter_riverpod: ^1.0.3
flutter_hooks: ^0.18.2+1
hooks_riverpod: ^1.0.3
go_router: ^3.0.4
palette_generator: ^0.3.3
audio_session: ^0.1.6+1
dev_dependencies:
flutter_test: