mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Merge branch 'build' of https://github.com/krtirtho/spotube into build
This commit is contained in:
commit
c1b6e61666
18
README.md
18
README.md
@ -8,7 +8,7 @@
|
|||||||
<img alt="GitHub release" src="https://img.shields.io/github/v/release/KRTirtho/spotube?color=%2316ba58&style=flat-square"/>
|
<img alt="GitHub release" src="https://img.shields.io/github/v/release/KRTirtho/spotube?color=%2316ba58&style=flat-square"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="LICENSE">
|
<a href="LICENSE">
|
||||||
<img alt="License" src="https://img.shields.io/aur/license/spotube?color=%2316ba58&style=flat-square"/>
|
<img alt="License" src="https://img.shields.io/aur/license/spotube-bin?color=%2316ba58&style=flat-square"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/KRTirtho">
|
<a href="https://github.com/KRTirtho">
|
||||||
<img alt="Maintainer" src="https://img.shields.io/badge/Maintainer-KRTirtho-%2316ba58?style=flat-square"/>
|
<img alt="Maintainer" src="https://img.shields.io/badge/Maintainer-KRTirtho-%2316ba58?style=flat-square"/>
|
||||||
@ -46,7 +46,7 @@ All the binaries are located in the [releases](https://github.com/krtirtho/spotu
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
Download the [setup file](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-windows-x86_64-setup.exe) & follow along the installer
|
Download the [setup file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-windows-x86_64-setup.exe) & follow along the installer
|
||||||
|
|
||||||
### Chocolatey
|
### Chocolatey
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
|
|||||||
<a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a>
|
<a href='https://flathub.org/apps/details/com.github.KRTirtho.Spotube'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a>
|
||||||
|
|
||||||
### Ubuntu/Debian/Linux Mint/Pop_!OS:
|
### Ubuntu/Debian/Linux Mint/Pop_!OS:
|
||||||
Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.deb) then double click it or run
|
Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.deb) then double click it or run
|
||||||
```bash
|
```bash
|
||||||
$ sudo apt install Spotube-linux-x86_64.deb
|
$ sudo apt install Spotube-linux-x86_64.deb
|
||||||
# or
|
# or
|
||||||
@ -91,10 +91,13 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
|
|||||||
|
|
||||||
|
|
||||||
### AppImage:
|
### AppImage:
|
||||||
Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
|
Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
|
||||||
|
|
||||||
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey stores or software centers or repositories**
|
## Mac OS
|
||||||
|
Download the [Mac OS Disk Image (.dmg) file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg) from the release & follow along the setup wizard
|
||||||
|
|
||||||
|
|
||||||
|
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey/homebrew stores or software centers or repositories**
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
There are some configurations that needs to be done to start using this software
|
There are some configurations that needs to be done to start using this software
|
||||||
@ -130,7 +133,7 @@ Also, you need a [genius](https://genius.com) account for **lyrics** & a API Cli
|
|||||||
- [x] Add support for show Lyric of currently playing track
|
- [x] Add support for show Lyric of currently playing track
|
||||||
- [x] Track download
|
- [x] Track download
|
||||||
- [ ] Support for playing/streaming podcasts/shows
|
- [ ] Support for playing/streaming podcasts/shows
|
||||||
- [ ] Artist, User & Album pages
|
- [x] Artist, User & Album pages
|
||||||
|
|
||||||
# Building from source
|
# Building from source
|
||||||
|
|
||||||
@ -149,7 +152,6 @@ $ flutter run -d <window|macos|linux>
|
|||||||
|
|
||||||
- Shows & Podcasts aren't supported as it'd require premium anyway
|
- Shows & Podcasts aren't supported as it'd require premium anyway
|
||||||
- OS Media Controls
|
- OS Media Controls
|
||||||
- Global Media Shortcuts/Keyboard Media Buttons
|
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
@ -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
|
Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about this application
|
||||||
|
|
||||||
|
|
||||||
<p align="center">© 2022 Spotube</p>
|
<p align="center">© 2022 Spotube</p>
|
||||||
|
@ -1,34 +1,30 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="oss.krtirtho.spotube">
|
||||||
package="oss.krtirtho.spotube">
|
|
||||||
<application
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
android:label="spotube"
|
|
||||||
android:name="${applicationName}"
|
<queries>
|
||||||
android:icon="@mipmap/ic_launcher">
|
<!-- If your app opens https URLs -->
|
||||||
<activity
|
<intent>
|
||||||
android:name=".MainActivity"
|
<action android:name="android.intent.action.VIEW" />
|
||||||
android:exported="true"
|
<data android:scheme="https" />
|
||||||
android:launchMode="singleTop"
|
</intent>
|
||||||
android:theme="@style/LaunchTheme"
|
</queries>
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
<application android:label="spotube" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">
|
||||||
android:windowSoftInputMode="adjustResize">
|
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
<intent-filter>
|
||||||
android:resource="@style/NormalTheme"
|
<action android:name="android.intent.action.MAIN" />
|
||||||
/>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<intent-filter>
|
</intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
</activity>
|
||||||
</intent-filter>
|
<!-- Don't delete the meta-data below.
|
||||||
</activity>
|
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
android:name="flutterEmbedding"
|
</application>
|
||||||
android:value="2" />
|
</manifest>
|
||||||
</application>
|
|
||||||
</manifest>
|
|
@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.3.50'
|
ext.kotlin_version = '1.6.10'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
BIN
assets/placeholder.png
Normal file
BIN
assets/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/warmer.mp3
Normal file
BIN
assets/warmer.mp3
Normal file
Binary file not shown.
@ -1,12 +1,12 @@
|
|||||||
pkgbase = spotube-bin
|
pkgbase = spotube-bin
|
||||||
pkgdesc = A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed
|
pkgdesc = A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed
|
||||||
pkgver = 1.2.0
|
pkgver = 1.2.0
|
||||||
pkgrel = 1
|
pkgrel = 2
|
||||||
url = https://github.com/KRTirtho/spotube/
|
url = https://github.com/KRTirtho/spotube/
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = BSD-4-Clause
|
license = BSD-4-Clause
|
||||||
depends = libkeybinder3
|
depends = libkeybinder3
|
||||||
source = https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.tar.xz
|
source = https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.tar.xz
|
||||||
md5sums = 0db87627ddf753bc7f09ebbb282184ee
|
md5sums = f49d21ef00c7d43eb70e7e9b2a7103c1
|
||||||
|
|
||||||
pkgname = spotube-bin
|
pkgname = spotube-bin
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Kingkor Roy Tirtho <krtirho@gmail.com>
|
# Maintainer: Kingkor Roy Tirtho <krtirho@gmail.com>
|
||||||
pkgname=spotube-bin
|
pkgname=spotube-bin
|
||||||
pkgver=1.2.0
|
pkgver=1.2.0
|
||||||
pkgrel=1
|
pkgrel=2
|
||||||
epoch=
|
epoch=
|
||||||
pkgdesc="A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed"
|
pkgdesc="A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed"
|
||||||
arch=(x86_64)
|
arch=(x86_64)
|
||||||
@ -21,16 +21,20 @@ install=
|
|||||||
changelog=
|
changelog=
|
||||||
source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/Spotube-linux-x86_64.tar.xz")
|
source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/Spotube-linux-x86_64.tar.xz")
|
||||||
noextract=()
|
noextract=()
|
||||||
md5sums=(0db87627ddf753bc7f09ebbb282184ee)
|
md5sums=(f49d21ef00c7d43eb70e7e9b2a7103c1)
|
||||||
validpgpkeys=()
|
validpgpkeys=()
|
||||||
|
|
||||||
package(){
|
package(){
|
||||||
install -dm755 "${pkgdir}/usr/share/icons/${pkgname}"
|
install -dm755 "${pkgdir}/usr/share/icons/spotube"
|
||||||
install -dm755 "${pkgdir}/usr/share/applications"
|
install -dm755 "${pkgdir}/usr/share/applications"
|
||||||
|
install -dm755 "${pkgdir}/usr/share/appdata"
|
||||||
install -dm755 "${pkgdir}/usr/share/${pkgname}"
|
install -dm755 "${pkgdir}/usr/share/${pkgname}"
|
||||||
install -dm755 "${pkgdir}/usr/bin"
|
install -dm755 "${pkgdir}/usr/bin"
|
||||||
cp -ra ./ "${pkgdir}/usr/share/${pkgname}"
|
|
||||||
cp ./spotube.desktop "${pkgdir}/usr/share/applications"
|
mv ./spotube.desktop "${pkgdir}/usr/share/applications"
|
||||||
cp ./spotube-logo.png "${pkgdir}/usr/share/icons/${pkgname}"
|
mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/"
|
||||||
ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/${pkgname}"
|
mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml"
|
||||||
|
cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}"
|
||||||
|
sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml"
|
||||||
|
ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube"
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ in verifying that this package's contents are trustworthy.
|
|||||||
Please go to releases page
|
Please go to releases page
|
||||||
https://github.com/KRTirtho/spotube/releases
|
https://github.com/KRTirtho/spotube/releases
|
||||||
|
|
||||||
Download same version as this choco package (example for v1.1.0)
|
Download same version as this choco package (example for v1.2.0)
|
||||||
https://github.com/KRTirtho/spotube/releases/tag/v1.0.1
|
https://github.com/KRTirtho/spotube/releases/tag/v1.0.1
|
||||||
|
|
||||||
1. get hashes. Run:
|
1. get hashes. Run:
|
||||||
@ -15,9 +15,9 @@ powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe
|
|||||||
|
|
||||||
2. The checksums should match the following:
|
2. The checksums should match the following:
|
||||||
---
|
---
|
||||||
Version Hashes for v1.1.0
|
Version Hashes for v1.2.0
|
||||||
|
|
||||||
|
|
||||||
Algorithm Hash Path
|
Algorithm Hash Path
|
||||||
--------- ---- ----
|
--------- ---- ----
|
||||||
SHA256 144fb4170b424ae9ecee8941354244cb9744c0913fdc69f730a8b5e40e56753d tools\Spotube-windows-x86_64-setup.exe
|
SHA256 02c032e1a2b8f60969b7a65c6a5e21df2bf5834cc8d8062cf56a2c8245a2a90e tools\Spotube-windows-x86_64-setup.exe
|
@ -37,5 +37,11 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
|
||||||
|
'$(inherited)',
|
||||||
|
'AUDIO_SESSION_MICROPHONE=0'
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,47 +1,54 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Sptube</string>
|
<string>Sptube</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>spotube</string>
|
<string>spotube</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true />
|
||||||
</dict>
|
<key>NSAppTransportSecurity</key>
|
||||||
</plist>
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true />
|
||||||
|
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||||
|
<true />
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
@ -1,40 +1,42 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Album/AlbumView.dart';
|
import 'package:spotube/components/Album/AlbumView.dart';
|
||||||
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
|
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
|
||||||
|
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
||||||
import 'package:spotube/helpers/artist-to-string.dart';
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/helpers/simple-track-to-track.dart';
|
import 'package:spotube/helpers/simple-track-to-track.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpointValue.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends HookConsumerWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
const AlbumCard(this.album, {Key? key}) : super(key: key);
|
const AlbumCard(this.album, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = context.watch<Playback>();
|
Playback playback = ref.watch(playbackProvider);
|
||||||
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
||||||
playback.currentPlaylist!.id == album.id;
|
playback.currentPlaylist!.id == album.id;
|
||||||
|
final int marginH =
|
||||||
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
imageUrl: imageToUrlString(album.images),
|
imageUrl: imageToUrlString(album.images),
|
||||||
|
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||||
isPlaying: playback.currentPlaylist?.id != null &&
|
isPlaying: playback.currentPlaylist?.id != null &&
|
||||||
playback.currentPlaylist?.id == album.id,
|
playback.currentPlaylist?.id == album.id,
|
||||||
title: album.name!,
|
title: album.name!,
|
||||||
description:
|
description:
|
||||||
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
"Album • ${artistsToString<ArtistSimple>(album.artists ?? [])}",
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
GoRouter.of(context).push("/album/${album.id}", extra: album);
|
||||||
builder: (context) {
|
|
||||||
return AlbumView(album);
|
|
||||||
},
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
SpotifyApi spotify = context.read<SpotifyDI>().spotifyApi;
|
SpotifyApi spotify = ref.read(spotifyProvider);
|
||||||
if (isPlaylistPlaying) return;
|
if (isPlaylistPlaying) return;
|
||||||
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
|
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
|
||||||
.map((track) => simpleTrackToTrack(track, album))
|
.map((track) => simpleTrackToTrack(track, album))
|
||||||
@ -48,6 +50,7 @@ class AlbumCard extends StatelessWidget {
|
|||||||
thumbnail: album.images!.first.url!,
|
thumbnail: album.images!.first.url!,
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = tracks.first;
|
playback.setCurrentTrack = tracks.first;
|
||||||
|
await playback.startPlaying();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/components/Shared/TracksTableView.dart';
|
import 'package:spotube/components/Shared/TracksTableView.dart';
|
||||||
@ -8,11 +8,12 @@ import 'package:spotube/helpers/simple-track-to-track.dart';
|
|||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class AlbumView extends StatelessWidget {
|
class AlbumView extends ConsumerWidget {
|
||||||
final AlbumSimple album;
|
final AlbumSimple album;
|
||||||
const AlbumView(this.album, {Key? key}) : super(key: key);
|
const AlbumView(this.album, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) {
|
playPlaylist(Playback playback, List<Track> tracks,
|
||||||
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
|
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
@ -28,71 +29,74 @@ class AlbumView extends StatelessWidget {
|
|||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.currentTrack?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
}
|
}
|
||||||
|
await playback.startPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = context.watch<Playback>();
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
|
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
|
||||||
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
|
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
return Scaffold(
|
return SafeArea(
|
||||||
body: FutureBuilder<Iterable<TrackSimple>>(
|
child: Scaffold(
|
||||||
future: spotify.albums.getTracks(album.id!).all(),
|
body: FutureBuilder<Iterable<TrackSimple>>(
|
||||||
builder: (context, snapshot) {
|
future: spotify.albums.getTracks(album.id!).all(),
|
||||||
List<Track> tracks = snapshot.data?.map((trackSmp) {
|
builder: (context, snapshot) {
|
||||||
return simpleTrackToTrack(trackSmp, album);
|
List<Track> tracks = snapshot.data?.map((trackSmp) {
|
||||||
}).toList() ??
|
return simpleTrackToTrack(trackSmp, album);
|
||||||
[];
|
}).toList() ??
|
||||||
return Column(
|
[];
|
||||||
children: [
|
return Column(
|
||||||
PageWindowTitleBar(
|
children: [
|
||||||
leading: Row(
|
PageWindowTitleBar(
|
||||||
children: [
|
leading: Row(
|
||||||
// nav back
|
children: [
|
||||||
const BackButton(),
|
// nav back
|
||||||
// heart playlist
|
const BackButton(),
|
||||||
IconButton(
|
// heart playlist
|
||||||
icon: const Icon(Icons.favorite_outline_rounded),
|
IconButton(
|
||||||
onPressed: () {},
|
icon: const Icon(Icons.favorite_outline_rounded),
|
||||||
),
|
onPressed: () {},
|
||||||
// play playlist
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
isPlaylistPlaying
|
|
||||||
? Icons.stop_rounded
|
|
||||||
: Icons.play_arrow_rounded,
|
|
||||||
),
|
),
|
||||||
onPressed: snapshot.hasData
|
// play playlist
|
||||||
? () => playPlaylist(playback, tracks)
|
IconButton(
|
||||||
: null,
|
icon: Icon(
|
||||||
)
|
isPlaylistPlaying
|
||||||
],
|
? Icons.stop_rounded
|
||||||
),
|
: Icons.play_arrow_rounded,
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart' hide Page;
|
import 'package:flutter/material.dart' hide Page;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Album/AlbumCard.dart';
|
import 'package:spotube/components/Album/AlbumCard.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class ArtistAlbumView extends StatefulWidget {
|
class ArtistAlbumView extends ConsumerStatefulWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
const ArtistAlbumView(
|
const ArtistAlbumView(
|
||||||
@ -16,10 +16,10 @@ class ArtistAlbumView extends StatefulWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ArtistAlbumView> createState() => _ArtistAlbumViewState();
|
ConsumerState<ArtistAlbumView> createState() => _ArtistAlbumViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistAlbumViewState extends State<ArtistAlbumView> {
|
class _ArtistAlbumViewState extends ConsumerState<ArtistAlbumView> {
|
||||||
final PagingController<int, Album> _pagingController =
|
final PagingController<int, Album> _pagingController =
|
||||||
PagingController<int, Album>(firstPageKey: 0);
|
PagingController<int, Album>(firstPageKey: 0);
|
||||||
|
|
||||||
@ -39,10 +39,9 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
|
|||||||
|
|
||||||
_fetchPage(int pageKey) async {
|
_fetchPage(int pageKey) async {
|
||||||
try {
|
try {
|
||||||
SpotifyDI data = context.read<SpotifyDI>();
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
Page<Album> albums = await data.spotifyApi.artists
|
Page<Album> albums =
|
||||||
.albums(widget.artistId)
|
await spotifyApi.artists.albums(widget.artistId).getPage(8, pageKey);
|
||||||
.getPage(8, pageKey);
|
|
||||||
|
|
||||||
var items = albums.items!.toList();
|
var items = albums.items!.toList();
|
||||||
|
|
||||||
@ -60,32 +59,34 @@ class _ArtistAlbumViewState extends State<ArtistAlbumView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return SafeArea(
|
||||||
appBar: const PageWindowTitleBar(leading: BackButton()),
|
child: Scaffold(
|
||||||
body: Column(
|
appBar: const PageWindowTitleBar(leading: BackButton()),
|
||||||
children: [
|
body: Column(
|
||||||
Text(
|
children: [
|
||||||
widget.artistName,
|
Text(
|
||||||
style: Theme.of(context).textTheme.headline4,
|
widget.artistName,
|
||||||
),
|
style: Theme.of(context).textTheme.headline4,
|
||||||
Expanded(
|
),
|
||||||
child: PagedGridView(
|
Expanded(
|
||||||
pagingController: _pagingController,
|
child: PagedGridView(
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
pagingController: _pagingController,
|
||||||
maxCrossAxisExtent: 260,
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
childAspectRatio: 9 / 13,
|
maxCrossAxisExtent: 260,
|
||||||
crossAxisSpacing: 20,
|
childAspectRatio: 9 / 13,
|
||||||
mainAxisSpacing: 20,
|
crossAxisSpacing: 20,
|
||||||
),
|
mainAxisSpacing: 20,
|
||||||
padding: const EdgeInsets.all(10),
|
),
|
||||||
builderDelegate: PagedChildBuilderDelegate<Album>(
|
padding: const EdgeInsets.all(10),
|
||||||
itemBuilder: (context, item, index) {
|
builderDelegate: PagedChildBuilderDelegate<Album>(
|
||||||
return AlbumCard(item);
|
itemBuilder: (context, item, index) {
|
||||||
},
|
return AlbumCard(item);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistProfile.dart';
|
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatelessWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@ -9,13 +9,14 @@ class ArtistCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final backgroundImage = CachedNetworkImageProvider((artist
|
||||||
|
.images?.isNotEmpty ??
|
||||||
|
false)
|
||||||
|
? artist.images!.first.url!
|
||||||
|
: "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6");
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
GoRouter.of(context).push("/artist/${artist.id}");
|
||||||
builder: (context) {
|
|
||||||
return ArtistProfile(artist.id!);
|
|
||||||
},
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
@ -38,11 +39,7 @@ class ArtistCard extends StatelessWidget {
|
|||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
maxRadius: 80,
|
maxRadius: 80,
|
||||||
minRadius: 20,
|
minRadius: 20,
|
||||||
backgroundImage: CachedNetworkImageProvider((artist
|
backgroundImage: backgroundImage,
|
||||||
.images?.isNotEmpty ??
|
|
||||||
false)
|
|
||||||
? artist.images!.first.url!
|
|
||||||
: "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6"),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
artist.name!,
|
artist.name!,
|
||||||
|
@ -1,60 +1,83 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Album/AlbumCard.dart';
|
import 'package:spotube/components/Album/AlbumCard.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistAlbumView.dart';
|
|
||||||
import 'package:spotube/components/Artist/ArtistCard.dart';
|
import 'package:spotube/components/Artist/ArtistCard.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/components/Shared/TracksTableView.dart';
|
import 'package:spotube/components/Shared/TracksTableView.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/helpers/readable-number.dart';
|
import 'package:spotube/helpers/readable-number.dart';
|
||||||
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpointValue.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class ArtistProfile extends StatefulWidget {
|
class ArtistProfile extends HookConsumerWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
const ArtistProfile(this.artistId, {Key? key}) : super(key: key);
|
const ArtistProfile(this.artistId, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_ArtistProfileState createState() => _ArtistProfileState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
final parentScrollController = useScrollController();
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final chipTextVariant = useBreakpointValue(
|
||||||
|
sm: textTheme.bodySmall,
|
||||||
|
md: textTheme.bodyMedium,
|
||||||
|
lg: textTheme.headline6,
|
||||||
|
xl: textTheme.headline6,
|
||||||
|
xxl: textTheme.headline6,
|
||||||
|
);
|
||||||
|
|
||||||
class _ArtistProfileState extends State<ArtistProfile> {
|
final avatarWidth = useBreakpointValue(
|
||||||
@override
|
sm: MediaQuery.of(context).size.width * 0.50,
|
||||||
Widget build(BuildContext context) {
|
md: MediaQuery.of(context).size.width * 0.40,
|
||||||
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
|
lg: MediaQuery.of(context).size.width * 0.18,
|
||||||
return Scaffold(
|
xl: MediaQuery.of(context).size.width * 0.18,
|
||||||
appBar: const PageWindowTitleBar(
|
xxl: MediaQuery.of(context).size.width * 0.18,
|
||||||
leading: BackButton(),
|
);
|
||||||
),
|
|
||||||
body: FutureBuilder<Artist>(
|
|
||||||
future: spotify.artists.get(widget.artistId),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const Center(child: CircularProgressIndicator.adaptive());
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
final breakpoint = useBreakpoints();
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
return SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Scaffold(
|
||||||
children: [
|
appBar: const PageWindowTitleBar(
|
||||||
Row(
|
leading: BackButton(),
|
||||||
children: [
|
),
|
||||||
const SizedBox(width: 50),
|
body: FutureBuilder<Artist>(
|
||||||
CircleAvatar(
|
future: spotify.artists.get(artistId),
|
||||||
radius: MediaQuery.of(context).size.width * 0.18,
|
builder: (context, snapshot) {
|
||||||
backgroundImage: CachedNetworkImageProvider(
|
if (!snapshot.hasData) {
|
||||||
imageToUrlString(snapshot.data!.images),
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Flexible(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@ -64,21 +87,24 @@ class _ArtistProfileState extends State<ArtistProfile> {
|
|||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
borderRadius: BorderRadius.circular(50)),
|
borderRadius: BorderRadius.circular(50)),
|
||||||
child: Text(snapshot.data!.type!.toUpperCase(),
|
child: Text(snapshot.data!.type!.toUpperCase(),
|
||||||
style: Theme.of(context)
|
style: chipTextVariant?.copyWith(
|
||||||
.textTheme
|
color: Colors.white)),
|
||||||
.headline6
|
|
||||||
?.copyWith(color: Colors.white)),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
snapshot.data!.name!,
|
snapshot.data!.name!,
|
||||||
style: Theme.of(context).textTheme.headline2,
|
style: breakpoint.isSm
|
||||||
|
? textTheme.headline4
|
||||||
|
: textTheme.headline2,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers",
|
"${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers",
|
||||||
style: Theme.of(context).textTheme.headline5,
|
style: breakpoint.isSm
|
||||||
|
? textTheme.bodyText1
|
||||||
|
: textTheme.headline5,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// TODO: Implement check if user follows this artist
|
// TODO: Implement check if user follows this artist
|
||||||
// LIMITATION: spotify-dart lib
|
// LIMITATION: spotify-dart lib
|
||||||
@ -122,167 +148,170 @@ class _ArtistProfileState extends State<ArtistProfile> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 50),
|
||||||
const SizedBox(height: 50),
|
FutureBuilder<Iterable<Track>>(
|
||||||
FutureBuilder<Iterable<Track>>(
|
future:
|
||||||
future:
|
spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
|
||||||
spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
|
builder: (context, trackSnapshot) {
|
||||||
builder: (context, trackSnapshot) {
|
if (!trackSnapshot.hasData) {
|
||||||
if (!trackSnapshot.hasData) {
|
return const Center(
|
||||||
return const Center(
|
child: CircularProgressIndicator.adaptive());
|
||||||
child: CircularProgressIndicator.adaptive());
|
}
|
||||||
}
|
Playback playback = ref.watch(playbackProvider);
|
||||||
Playback playback = context.watch<Playback>();
|
var isPlaylistPlaying =
|
||||||
var isPlaylistPlaying =
|
playback.currentPlaylist?.id == snapshot.data?.id;
|
||||||
playback.currentPlaylist?.id == snapshot.data?.id;
|
playPlaylist(List<Track> tracks,
|
||||||
playPlaylist(List<Track> tracks, {Track? currentTrack}) {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
playback.setCurrentPlaylist = CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: snapshot.data!.id!,
|
id: snapshot.data!.id!,
|
||||||
name: "${snapshot.data!.name!} To Tracks",
|
name: "${snapshot.data!.name!} To Tracks",
|
||||||
thumbnail: imageToUrlString(snapshot.data?.images),
|
thumbnail: imageToUrlString(snapshot.data?.images),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.currentTrack?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
|
}
|
||||||
|
await playback.startPlaying();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return Column(children: [
|
return Column(children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Top Tracks",
|
"Top Tracks",
|
||||||
style: Theme.of(context).textTheme.headline4,
|
style: Theme.of(context).textTheme.headline4,
|
||||||
),
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(50),
|
|
||||||
),
|
),
|
||||||
child: IconButton(
|
Container(
|
||||||
icon: Icon(isPlaylistPlaying
|
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||||
? Icons.stop_rounded
|
decoration: BoxDecoration(
|
||||||
: Icons.play_arrow_rounded),
|
color: Theme.of(context).primaryColor,
|
||||||
color: Colors.white,
|
borderRadius: BorderRadius.circular(50),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
);
|
child: IconButton(
|
||||||
}) ??
|
icon: Icon(isPlaylistPlaying
|
||||||
[],
|
? Icons.stop_rounded
|
||||||
]);
|
: Icons.play_arrow_rounded),
|
||||||
},
|
color: Colors.white,
|
||||||
),
|
onPressed: trackSnapshot.hasData
|
||||||
const SizedBox(height: 50),
|
? () => playPlaylist(
|
||||||
Row(
|
trackSnapshot.data!.toList())
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
: null,
|
||||||
children: [
|
),
|
||||||
Text(
|
)
|
||||||
"Albums",
|
],
|
||||||
style: Theme.of(context).textTheme.headline4,
|
),
|
||||||
),
|
...trackSnapshot.data
|
||||||
TextButton(
|
?.toList()
|
||||||
child: const Text("See All"),
|
.asMap()
|
||||||
onPressed: () {
|
.entries
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
.map((track) {
|
||||||
builder: (context) => ArtistAlbumView(
|
String duration =
|
||||||
widget.artistId,
|
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
snapshot.data?.name ?? "KRTX",
|
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: 20),
|
||||||
const SizedBox(height: 10),
|
Text(
|
||||||
FutureBuilder<List<Album>>(
|
"Fans also likes",
|
||||||
future: spotify.artists
|
style: Theme.of(context).textTheme.headline4,
|
||||||
.albums(snapshot.data!.id!)
|
),
|
||||||
.getPage(5, 0)
|
const SizedBox(height: 10),
|
||||||
.then((al) => al.items?.toList() ?? []),
|
FutureBuilder<Iterable<Artist>>(
|
||||||
builder: (context, snapshot) {
|
future: spotify.artists.getRelatedArtists(artistId),
|
||||||
if (!snapshot.hasData) {
|
builder: (context, snapshot) {
|
||||||
return const Center(
|
if (!snapshot.hasData) {
|
||||||
child: CircularProgressIndicator.adaptive());
|
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 20,
|
spacing: 20,
|
||||||
runSpacing: 20,
|
runSpacing: 20,
|
||||||
children: snapshot.data
|
children: snapshot.data
|
||||||
?.map((artist) => ArtistCard(artist))
|
?.map((artist) => ArtistCard(artist))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart' hide Page;
|
import 'package:flutter/material.dart' hide Page;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistGenreView.dart';
|
import 'package:spotube/hooks/usePagingController.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class CategoryCard extends StatefulWidget {
|
class CategoryCard extends HookWidget {
|
||||||
final Category category;
|
final Category category;
|
||||||
final Iterable<PlaylistSimple>? playlists;
|
final Iterable<PlaylistSimple>? playlists;
|
||||||
const CategoryCard(
|
const CategoryCard(
|
||||||
@ -14,11 +16,6 @@ class CategoryCard extends StatefulWidget {
|
|||||||
this.playlists,
|
this.playlists,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
_CategoryCardState createState() => _CategoryCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CategoryCardState extends State<CategoryCard> {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -26,59 +23,81 @@ class _CategoryCardState extends State<CategoryCard> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.category.name ?? "Unknown",
|
category.name ?? "Unknown",
|
||||||
style: Theme.of(context).textTheme.headline5,
|
style: Theme.of(context).textTheme.headline5,
|
||||||
),
|
),
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) {
|
|
||||||
return PlaylistGenreView(
|
|
||||||
widget.category.id!,
|
|
||||||
widget.category.name!,
|
|
||||||
playlists: widget.playlists,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text("See all"),
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Consumer<SpotifyDI>(
|
HookConsumer(
|
||||||
builder: (context, data, child) {
|
builder: (context, ref, child) {
|
||||||
return FutureBuilder<Iterable<PlaylistSimple>>(
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
future: widget.playlists == null
|
final scrollController = useScrollController();
|
||||||
? (widget.category.id != "user-featured-playlists"
|
final pagingController =
|
||||||
? data.spotifyApi.playlists
|
usePagingController<int, PlaylistSimple>(firstPageKey: 0);
|
||||||
.getByCategoryId(widget.category.id!)
|
|
||||||
: data.spotifyApi.playlists.featured)
|
final _error = useState(false);
|
||||||
.getPage(4, 0)
|
final mounted = useIsMounted();
|
||||||
.then((value) => value.items ?? [])
|
|
||||||
: Future.value(widget.playlists),
|
useEffect(() {
|
||||||
builder: (context, snapshot) {
|
listener(pageKey) async {
|
||||||
if (snapshot.hasError) {
|
try {
|
||||||
return const Center(child: Text("Error occurred"));
|
if (playlists != null &&
|
||||||
|
playlists?.isNotEmpty == true &&
|
||||||
|
mounted()) {
|
||||||
|
return pagingController.appendLastPage(playlists!.toList());
|
||||||
}
|
}
|
||||||
if (!snapshot.hasData) {
|
final Page<PlaylistSimple> page = await (category.id !=
|
||||||
return const Center(
|
"user-featured-playlists"
|
||||||
child: CircularProgressIndicator.adaptive(),
|
? spotifyApi.playlists.getByCategoryId(category.id!)
|
||||||
);
|
: spotifyApi.playlists.featured)
|
||||||
|
.getPage(3, pageKey);
|
||||||
|
|
||||||
|
if (!mounted()) return;
|
||||||
|
if (page.isLast && page.items != null) {
|
||||||
|
pagingController.appendLastPage(page.items!.toList());
|
||||||
|
} else if (page.items != null) {
|
||||||
|
pagingController.appendPage(
|
||||||
|
page.items!.toList(), page.nextOffset);
|
||||||
}
|
}
|
||||||
return Wrap(
|
if (_error.value) _error.value = false;
|
||||||
spacing: 20,
|
} catch (e, stack) {
|
||||||
runSpacing: 20,
|
if (mounted()) {
|
||||||
children: snapshot.data!
|
if (!_error.value) _error.value = true;
|
||||||
.map((playlist) => PlaylistCard(playlist))
|
pagingController.error = e;
|
||||||
.toList(),
|
}
|
||||||
);
|
print(
|
||||||
});
|
"[CategoryCard.pagingController.addPageRequestListener] $e");
|
||||||
|
print(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagingController.addPageRequestListener(listener);
|
||||||
|
return () {
|
||||||
|
pagingController.removePageRequestListener(listener);
|
||||||
|
};
|
||||||
|
}, [_error]);
|
||||||
|
|
||||||
|
if (_error.value) return const Text("Something Went Wrong");
|
||||||
|
return SizedBox(
|
||||||
|
height: 245,
|
||||||
|
child: Scrollbar(
|
||||||
|
controller: scrollController,
|
||||||
|
child: PagedListView<int, PlaylistSimple>(
|
||||||
|
shrinkWrap: true,
|
||||||
|
pagingController: pagingController,
|
||||||
|
scrollController: scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
builderDelegate: PagedChildBuilderDelegate<PlaylistSimple>(
|
||||||
|
itemBuilder: (context, playlist, index) {
|
||||||
|
return PlaylistCard(playlist);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -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()
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
220
lib/components/Home/Home.dart
Normal file
220
lib/components/Home/Home.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
123
lib/components/Home/Sidebar.dart
Normal file
123
lib/components/Home/Sidebar.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
42
lib/components/Home/SpotubeNavigationBar.dart
Normal file
42
lib/components/Home/SpotubeNavigationBar.dart
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,18 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistCard.dart';
|
import 'package:spotube/components/Artist/ArtistCard.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class UserArtists extends StatefulWidget {
|
class UserArtists extends ConsumerStatefulWidget {
|
||||||
const UserArtists({Key? key}) : super(key: key);
|
const UserArtists({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UserArtists> createState() => _UserArtistsState();
|
ConsumerState<UserArtists> createState() => _UserArtistsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserArtistsState extends State<UserArtists> {
|
class _UserArtistsState extends ConsumerState<UserArtists> {
|
||||||
final PagingController<String, Artist> _pagingController =
|
final PagingController<String, Artist> _pagingController =
|
||||||
PagingController(firstPageKey: "");
|
PagingController(firstPageKey: "");
|
||||||
|
|
||||||
@ -22,8 +22,8 @@ class _UserArtistsState extends State<UserArtists> {
|
|||||||
WidgetsBinding.instance?.addPostFrameCallback((timestamp) {
|
WidgetsBinding.instance?.addPostFrameCallback((timestamp) {
|
||||||
_pagingController.addPageRequestListener((pageKey) async {
|
_pagingController.addPageRequestListener((pageKey) async {
|
||||||
try {
|
try {
|
||||||
SpotifyDI data = context.read<SpotifyDI>();
|
SpotifyApi spotifyApi = ref.read(spotifyProvider);
|
||||||
CursorPage<Artist> artists = await data.spotifyApi.me
|
CursorPage<Artist> artists = await spotifyApi.me
|
||||||
.following(FollowingType.artist)
|
.following(FollowingType.artist)
|
||||||
.getPage(15, pageKey);
|
.getPage(15, pageKey);
|
||||||
|
|
||||||
@ -51,10 +51,10 @@ class _UserArtistsState extends State<UserArtists> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SpotifyDI data = context.watch<SpotifyDI>();
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
return FutureBuilder<CursorPage<Artist>>(
|
return FutureBuilder<CursorPage<Artist>>(
|
||||||
future: data.spotifyApi.me.following(FollowingType.artist).first(),
|
future: spotifyApi.me.following(FollowingType.artist).first(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator.adaptive());
|
return const Center(child: CircularProgressIndicator.adaptive());
|
||||||
|
@ -2,14 +2,8 @@ import 'package:flutter/material.dart' hide Image;
|
|||||||
import 'package:spotube/components/Library/UserArtists.dart';
|
import 'package:spotube/components/Library/UserArtists.dart';
|
||||||
import 'package:spotube/components/Library/UserPlaylists.dart';
|
import 'package:spotube/components/Library/UserPlaylists.dart';
|
||||||
|
|
||||||
class UserLibrary extends StatefulWidget {
|
class UserLibrary extends StatelessWidget {
|
||||||
const UserLibrary({Key? key}) : super(key: key);
|
const UserLibrary({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
_UserLibraryState createState() => _UserLibraryState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UserLibraryState extends State<UserLibrary> {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import 'package:flutter/material.dart' hide Image;
|
import 'package:flutter/material.dart' hide Image;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class UserPlaylists extends StatelessWidget {
|
class UserPlaylists extends ConsumerWidget {
|
||||||
const UserPlaylists({Key? key}) : super(key: key);
|
const UserPlaylists({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, ref) {
|
||||||
SpotifyDI data = context.watch<SpotifyDI>();
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
return FutureBuilder<Iterable<PlaylistSimple>>(
|
return FutureBuilder<Iterable<PlaylistSimple>>(
|
||||||
future: data.spotifyApi.playlists.me.all(),
|
future: spotifyApi.playlists.me.all(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator.adaptive());
|
return const Center(child: CircularProgressIndicator.adaptive());
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/components/Shared/Hyperlink.dart';
|
import 'package:spotube/components/Shared/Hyperlink.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
@ -8,127 +9,109 @@ import 'package:spotube/models/LocalStorageKeys.dart';
|
|||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
|
||||||
class Login extends StatefulWidget {
|
class Login extends HookConsumerWidget {
|
||||||
const Login({Key? key}) : super(key: key);
|
const Login({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_LoginState createState() => _LoginState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
var clientIdController = useTextEditingController();
|
||||||
|
var clientSecretController = useTextEditingController();
|
||||||
|
var accessTokenController = useTextEditingController();
|
||||||
|
var fieldError = useState(false);
|
||||||
|
|
||||||
class _LoginState extends State<Login> {
|
Future handleLogin(Auth authState) async {
|
||||||
String clientId = "";
|
try {
|
||||||
String clientSecret = "";
|
if (clientIdController.value.text == "" ||
|
||||||
String accessToken = "";
|
clientSecretController.value.text == "") {
|
||||||
bool _fieldError = false;
|
fieldError.value = true;
|
||||||
|
}
|
||||||
Future handleLogin(Auth authState) async {
|
await oauthLogin(
|
||||||
try {
|
ref.read(authProvider),
|
||||||
if (clientId == "" || clientSecret == "") {
|
clientId: clientIdController.value.text,
|
||||||
return setState(() {
|
clientSecret: clientSecretController.value.text,
|
||||||
_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"),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
} 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"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,54 +1,62 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Settings.dart';
|
import 'package:spotube/components/Settings.dart';
|
||||||
|
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
||||||
import 'package:spotube/helpers/artist-to-string.dart';
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
import 'package:spotube/helpers/getLyrics.dart';
|
import 'package:spotube/helpers/getLyrics.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
|
||||||
class Lyrics extends StatefulWidget {
|
class Lyrics extends HookConsumerWidget {
|
||||||
const Lyrics({Key? key}) : super(key: key);
|
const Lyrics({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Lyrics> createState() => _LyricsState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
UserPreferences userPreferences = ref.watch(userPreferencesProvider);
|
||||||
class _LyricsState extends State<Lyrics> {
|
var lyrics = useState({});
|
||||||
Map<String, String> _lyrics = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Playback playback = context.watch<Playback>();
|
|
||||||
UserPreferences userPreferences = context.watch<UserPreferences>();
|
|
||||||
|
|
||||||
bool hasToken = (userPreferences.geniusAccessToken != null ||
|
bool hasToken = (userPreferences.geniusAccessToken != null ||
|
||||||
(userPreferences.geniusAccessToken?.isNotEmpty ?? false));
|
(userPreferences.geniusAccessToken?.isNotEmpty ?? false));
|
||||||
|
var lyricsFuture = useMemoized(() {
|
||||||
if (playback.currentTrack != null &&
|
if (playback.currentTrack == null ||
|
||||||
hasToken &&
|
!hasToken ||
|
||||||
playback.currentTrack!.id != _lyrics["id"]) {
|
(playback.currentTrack?.id != null &&
|
||||||
getLyrics(
|
playback.currentTrack?.id == lyrics.value["id"])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getLyrics(
|
||||||
playback.currentTrack!.name!,
|
playback.currentTrack!.name!,
|
||||||
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
artistsToString<Artist>(playback.currentTrack!.artists ?? []),
|
||||||
apiKey: userPreferences.geniusAccessToken!,
|
apiKey: userPreferences.geniusAccessToken!,
|
||||||
optimizeQuery: true,
|
optimizeQuery: true,
|
||||||
).then((lyrics) {
|
);
|
||||||
if (lyrics != null) {
|
}, [playback.currentTrack]);
|
||||||
setState(() {
|
|
||||||
_lyrics = {"lyrics": lyrics, "id": playback.currentTrack!.id!};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_lyrics["lyrics"] != null && playback.currentTrack == null) {
|
var lyricsSnapshot = useFuture(lyricsFuture);
|
||||||
setState(() {
|
|
||||||
_lyrics = {};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (!hasToken) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -62,11 +70,7 @@ class _LyricsState extends State<Lyrics> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
GoRouter.of(context).push("/settings");
|
||||||
builder: (context) {
|
|
||||||
return const Settings();
|
|
||||||
},
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
child: const Text("Add Access Token"))
|
child: const Text("Add Access Token"))
|
||||||
],
|
],
|
||||||
@ -99,9 +103,10 @@ class _LyricsState extends State<Lyrics> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
_lyrics["lyrics"] == null && playback.currentTrack == null
|
lyrics.value["lyrics"] == null &&
|
||||||
|
playback.currentTrack == null
|
||||||
? "No Track being played currently"
|
? "No Track being played currently"
|
||||||
: _lyrics["lyrics"]!,
|
: lyrics.value["lyrics"]!,
|
||||||
style: Theme.of(context).textTheme.headline6,
|
style: Theme.of(context).textTheme.headline6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,388 +1,185 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:spotify/spotify.dart' hide Image;
|
||||||
|
import 'package:spotube/components/Player/PlayerOverlay.dart';
|
||||||
|
import 'package:spotube/components/Player/PlayerTrackDetails.dart';
|
||||||
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
|
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
|
||||||
import 'package:spotube/components/Player/PlayerControls.dart';
|
import 'package:spotube/components/Player/PlayerControls.dart';
|
||||||
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
|
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/helpers/search-youtube.dart';
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|
||||||
|
|
||||||
class Player extends StatefulWidget {
|
class Player extends HookConsumerWidget {
|
||||||
const Player({Key? key}) : super(key: key);
|
const Player({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlayerState createState() => _PlayerState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
class _PlayerState extends State<Player> with WidgetsBindingObserver {
|
final _volume = useState(0.0);
|
||||||
late AudioPlayer player;
|
|
||||||
bool _isPlaying = false;
|
|
||||||
bool _shuffled = false;
|
|
||||||
Duration? _duration;
|
|
||||||
|
|
||||||
String? _currentTrackId;
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
double _volume = 0;
|
final AudioPlayer player = playback.player;
|
||||||
|
|
||||||
late YoutubeExplode youtube;
|
final Future<SharedPreferences> future =
|
||||||
|
useMemoized(SharedPreferences.getInstance);
|
||||||
|
final AsyncSnapshot<SharedPreferences?> localStorage =
|
||||||
|
useFuture(future, initialData: null);
|
||||||
|
|
||||||
@override
|
useEffect(() {
|
||||||
void initState() {
|
/// warm up the audio player before playing actual audio
|
||||||
try {
|
/// It's for resolving unresolved issue related to just_audio's
|
||||||
super.initState();
|
/// [disposeAllPlayers] method which is throwing
|
||||||
player = AudioPlayer();
|
/// [UnimplementedException] in the [PlatformInterface]
|
||||||
youtube = YoutubeExplode();
|
/// implementation
|
||||||
|
player.setAsset("assets/warmer.mp3");
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
WidgetsBinding.instance?.addObserver(this);
|
useEffect(() {
|
||||||
WidgetsBinding.instance?.addPostFrameCallback(_init);
|
if (localStorage.hasData) {
|
||||||
} catch (e, stack) {
|
_volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
|
||||||
print("[Player.initState()] $e");
|
player.volume;
|
||||||
print(stack);
|
}
|
||||||
}
|
return null;
|
||||||
}
|
}, [localStorage.data]);
|
||||||
|
|
||||||
_init(Duration timeStamp) async {
|
String albumArt = useMemoized(
|
||||||
try {
|
() => imageToUrlString(
|
||||||
setState(() {
|
playback.currentTrack?.album?.images,
|
||||||
_volume = player.volume;
|
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
|
||||||
});
|
),
|
||||||
player.playingStream.listen((playing) async {
|
[playback.currentTrack?.album?.images],
|
||||||
setState(() {
|
);
|
||||||
_isPlaying = playing;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
player.durationStream.listen((duration) async {
|
final entryRef = useRef<OverlayEntry?>(null);
|
||||||
if (duration != null) {
|
|
||||||
// Actually things doesn't work all the time as they were
|
|
||||||
// described. So instead of listening to a `playback.ready`
|
|
||||||
// stream, it has to listen to duration stream since duration
|
|
||||||
// is always added to the Stream sink after all icyMetadata has
|
|
||||||
// been loaded thus indicating buffering started
|
|
||||||
if (duration != Duration.zero && duration != _duration) {
|
|
||||||
// this line is for prev/next or already playing playlist
|
|
||||||
if (player.playing) await player.pause();
|
|
||||||
await player.play();
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_duration = duration;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
player.processingStateStream.listen((event) async {
|
disposeOverlay() {
|
||||||
try {
|
try {
|
||||||
if (event == ProcessingState.completed && _currentTrackId != null) {
|
entryRef.value?.remove();
|
||||||
_movePlaylistPositionBy(1);
|
entryRef.value = null;
|
||||||
}
|
} catch (e, stack) {
|
||||||
} catch (e, stack) {
|
if (e is! AssertionError) {
|
||||||
print("[PrecessingStateStreamListener] $e");
|
print("[Player.useEffect.cleanup] $e");
|
||||||
print(stack);
|
print(stack);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
} catch (e) {
|
|
||||||
print("[Player._init()]: $e");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
useEffect(() {
|
||||||
void dispose() {
|
// clearing the overlay-entry as passing the already available
|
||||||
WidgetsBinding.instance?.removeObserver(this);
|
// entry will result in splashing while resizing the window
|
||||||
player.dispose();
|
if (entryRef.value != null) disposeOverlay();
|
||||||
youtube.close();
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
|
||||||
super.dispose();
|
entryRef.value = OverlayEntry(
|
||||||
}
|
opaque: false,
|
||||||
|
builder: (context) => PlayerOverlay(albumArt: albumArt),
|
||||||
@override
|
);
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
// I can't believe useEffect doesn't run Post Frame aka
|
||||||
if (state == AppLifecycleState.paused) {
|
// after rendering/painting the UI
|
||||||
// Release the player's resources when not in use. We use "stop" so that
|
// `My disappointment is immeasurable and my day is ruined` XD
|
||||||
// if the app resumes later, it will still remember what position to
|
WidgetsBinding.instance?.addPostFrameCallback((time) {
|
||||||
// resume from.
|
Overlay.of(context)?.insert(entryRef.value!);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
return () {
|
||||||
}
|
disposeOverlay();
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
Future _playTrack(Track currentTrack, Playback playback) async {
|
// returning an empty non spacious Container as the overlay will take
|
||||||
try {
|
// place in the global overlay stack aka [_entries]
|
||||||
if (currentTrack.id != _currentTrackId) {
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
|
||||||
if (currentTrack.uri != null) {
|
return Container();
|
||||||
await player
|
|
||||||
.setAudioSource(
|
|
||||||
AudioSource.uri(Uri.parse(currentTrack.uri!)),
|
|
||||||
preload: true,
|
|
||||||
)
|
|
||||||
.then((value) async {
|
|
||||||
setState(() {
|
|
||||||
_currentTrackId = currentTrack.id;
|
|
||||||
if (_duration != null) {
|
|
||||||
_duration = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var ytTrack = await toYoutubeTrack(youtube, currentTrack);
|
|
||||||
if (playback.setTrackUriById(currentTrack.id!, ytTrack.uri!)) {
|
|
||||||
await player
|
|
||||||
.setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!)))
|
|
||||||
.then((value) {
|
|
||||||
setState(() {
|
|
||||||
_currentTrackId = currentTrack.id;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e, stack) {
|
|
||||||
print("[Player._playTrack()] $e");
|
|
||||||
print(stack);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_onNext() async {
|
|
||||||
try {
|
|
||||||
await player.pause();
|
|
||||||
await player.seek(Duration.zero);
|
|
||||||
_movePlaylistPositionBy(1);
|
|
||||||
print("ON NEXT");
|
|
||||||
} catch (e, stack) {
|
|
||||||
print("[PlayerControls.onNext()] $e");
|
|
||||||
print(stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onPrevious() async {
|
|
||||||
try {
|
|
||||||
await player.pause();
|
|
||||||
await player.seek(Duration.zero);
|
|
||||||
_movePlaylistPositionBy(-1);
|
|
||||||
} catch (e, stack) {
|
|
||||||
print("[PlayerControls.onPrevious()] $e");
|
|
||||||
print(stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
return Container(
|
||||||
color: Theme.of(context).backgroundColor,
|
color: Theme.of(context).backgroundColor,
|
||||||
child: Consumer<Playback>(
|
child: Material(
|
||||||
builder: (context, playback, widget) {
|
type: MaterialType.transparency,
|
||||||
if (playback.currentPlaylist != null &&
|
child: Row(
|
||||||
playback.currentTrack != null) {
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
_playTrack(playback.currentTrack!, playback);
|
children: [
|
||||||
}
|
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
||||||
|
// controls
|
||||||
String? albumArt = imageToUrlString(
|
const Expanded(
|
||||||
playback.currentTrack?.album?.images,
|
flex: 3,
|
||||||
index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
|
child: PlayerControls(),
|
||||||
);
|
|
||||||
|
|
||||||
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(() {}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
// 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!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,120 +1,67 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
||||||
import 'package:spotube/models/GlobalKeyActions.dart';
|
import 'package:spotube/hooks/playback.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class PlayerControls extends StatefulWidget {
|
class PlayerControls extends HookConsumerWidget {
|
||||||
final Stream<Duration> positionStream;
|
final Color? iconColor;
|
||||||
final bool isPlaying;
|
|
||||||
final Duration duration;
|
|
||||||
final bool shuffled;
|
|
||||||
final Function? onStop;
|
|
||||||
final Function? onShuffle;
|
|
||||||
final Function(double value)? onSeek;
|
|
||||||
final Function? onNext;
|
|
||||||
final Function? onPrevious;
|
|
||||||
final Function? onPlay;
|
|
||||||
final Function? onPause;
|
|
||||||
const PlayerControls({
|
const PlayerControls({
|
||||||
required this.positionStream,
|
this.iconColor,
|
||||||
required this.isPlaying,
|
|
||||||
required this.duration,
|
|
||||||
required this.shuffled,
|
|
||||||
this.onShuffle,
|
|
||||||
this.onStop,
|
|
||||||
this.onSeek,
|
|
||||||
this.onNext,
|
|
||||||
this.onPrevious,
|
|
||||||
this.onPlay,
|
|
||||||
this.onPause,
|
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlayerControlsState createState() => _PlayerControlsState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
final Playback playback = ref.watch(playbackProvider);
|
||||||
|
final AudioPlayer player = playback.player;
|
||||||
|
|
||||||
class _PlayerControlsState extends State<PlayerControls> {
|
final _shuffled = useState(false);
|
||||||
StreamSubscription? _timePositionListener;
|
final _duration = useState<Duration?>(playback.duration);
|
||||||
late List<GlobalKeyActions> _hotKeys = [];
|
|
||||||
|
|
||||||
@override
|
useEffect(() {
|
||||||
void dispose() async {
|
listener(Duration? duration) {
|
||||||
await _timePositionListener?.cancel();
|
_duration.value = duration;
|
||||||
Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
|
}
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_playOrPause(key) async {
|
playback.addDurationChangeListener(listener);
|
||||||
try {
|
|
||||||
widget.isPlaying ? widget.onPause?.call() : await widget.onPlay?.call();
|
|
||||||
} catch (e, stack) {
|
|
||||||
print("[PlayPauseShortcut] $e");
|
|
||||||
print(stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_configureHotKeys(UserPreferences preferences) async {
|
return () => playback.removeDurationChangeListener(listener);
|
||||||
await Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)))
|
}, []);
|
||||||
.then((val) async {
|
|
||||||
_hotKeys = [
|
|
||||||
GlobalKeyActions(
|
|
||||||
HotKey(KeyCode.space, scope: HotKeyScope.inapp),
|
|
||||||
_playOrPause,
|
|
||||||
),
|
|
||||||
if (preferences.nextTrackHotKey != null)
|
|
||||||
GlobalKeyActions(
|
|
||||||
preferences.nextTrackHotKey!, (key) => widget.onNext?.call()),
|
|
||||||
if (preferences.prevTrackHotKey != null)
|
|
||||||
GlobalKeyActions(
|
|
||||||
preferences.prevTrackHotKey!, (key) => widget.onPrevious?.call()),
|
|
||||||
if (preferences.playPauseHotKey != null)
|
|
||||||
GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause)
|
|
||||||
];
|
|
||||||
await Future.wait(
|
|
||||||
_hotKeys.map((e) {
|
|
||||||
return hotKeyManager.register(
|
|
||||||
e.hotKey,
|
|
||||||
keyDownHandler: e.onKeyDown,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
final onNext = useNextTrack(playback);
|
||||||
Widget build(BuildContext context) {
|
|
||||||
UserPreferences preferences = context.watch<UserPreferences>();
|
final onPrevious = usePreviousTrack(playback);
|
||||||
_configureHotKeys(preferences);
|
|
||||||
|
final _playOrPause = useTogglePlayPause(playback);
|
||||||
|
|
||||||
|
final duration = _duration.value ?? Duration.zero;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
constraints: const BoxConstraints(maxWidth: 700),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
StreamBuilder<Duration>(
|
StreamBuilder<Duration>(
|
||||||
stream: widget.positionStream,
|
stream: player.positionStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
var totalMinutes =
|
final totalMinutes =
|
||||||
zeroPadNumStr(widget.duration.inMinutes.remainder(60));
|
zeroPadNumStr(duration.inMinutes.remainder(60));
|
||||||
var totalSeconds =
|
final totalSeconds =
|
||||||
zeroPadNumStr(widget.duration.inSeconds.remainder(60));
|
zeroPadNumStr(duration.inSeconds.remainder(60));
|
||||||
var currentMinutes = snapshot.hasData
|
final currentMinutes = snapshot.hasData
|
||||||
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
|
? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
|
||||||
: "00";
|
: "00";
|
||||||
var currentSeconds = snapshot.hasData
|
final currentSeconds = snapshot.hasData
|
||||||
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
|
? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
|
||||||
: "00";
|
: "00";
|
||||||
|
|
||||||
var sliderMax = widget.duration.inSeconds;
|
final sliderMax = duration.inSeconds;
|
||||||
var sliderValue = snapshot.data?.inSeconds ?? 0;
|
final sliderValue = snapshot.data?.inSeconds ?? 0;
|
||||||
return Row(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Slider.adaptive(
|
||||||
child: Slider.adaptive(
|
|
||||||
// cannot divide by zero
|
// cannot divide by zero
|
||||||
// there's an edge case for value being bigger
|
// there's an edge case for value being bigger
|
||||||
// than total duration. Keeping it resolved
|
// than total duration. Keeping it resolved
|
||||||
@ -123,50 +70,95 @@ class _PlayerControlsState extends State<PlayerControls> {
|
|||||||
: sliderValue / sliderMax,
|
: sliderValue / sliderMax,
|
||||||
onChanged: (value) {},
|
onChanged: (value) {},
|
||||||
onChangeEnd: (value) {
|
onChangeEnd: (value) {
|
||||||
widget.onSeek?.call(value * sliderMax);
|
player.seek(
|
||||||
|
Duration(
|
||||||
|
seconds: (value * sliderMax).toInt(),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
activeColor: iconColor,
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Text(
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
"$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds",
|
child: Row(
|
||||||
)
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
],
|
children: [
|
||||||
);
|
Text(
|
||||||
}),
|
"$currentMinutes:$currentSeconds",
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
Text("$totalMinutes:$totalSeconds"),
|
||||||
children: [
|
],
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.shuffle_rounded),
|
),
|
||||||
color:
|
],
|
||||||
widget.shuffled ? Theme.of(context).primaryColor : null,
|
);
|
||||||
onPressed: () {
|
}),
|
||||||
widget.onShuffle?.call();
|
Row(
|
||||||
}),
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
IconButton(
|
children: [
|
||||||
icon: const Icon(Icons.skip_previous_rounded),
|
IconButton(
|
||||||
onPressed: () {
|
icon: const Icon(Icons.shuffle_rounded),
|
||||||
widget.onPrevious?.call();
|
color: _shuffled.value
|
||||||
}),
|
? Theme.of(context).primaryColor
|
||||||
IconButton(
|
: iconColor,
|
||||||
icon: Icon(
|
onPressed: () {
|
||||||
widget.isPlaying
|
if (playback.currentTrack == null ||
|
||||||
? Icons.pause_rounded
|
playback.currentPlaylist == null) {
|
||||||
: Icons.play_arrow_rounded,
|
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),
|
icon: const Icon(Icons.skip_next_rounded),
|
||||||
onPressed: () => widget.onNext?.call()),
|
onPressed: () => onNext(),
|
||||||
IconButton(
|
color: iconColor,
|
||||||
icon: const Icon(Icons.stop_rounded),
|
),
|
||||||
onPressed: () => widget.onStop?.call(),
|
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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
94
lib/components/Player/PlayerOverlay.dart
Normal file
94
lib/components/Player/PlayerOverlay.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
73
lib/components/Player/PlayerTrackDetails.dart
Normal file
73
lib/components/Player/PlayerTrackDetails.dart
Normal 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 ?? [],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
100
lib/components/Player/PlayerView.dart
Normal file
100
lib/components/Player/PlayerView.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,45 +1,46 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistView.dart';
|
import 'package:spotube/components/Playlist/PlaylistView.dart';
|
||||||
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
|
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
|
||||||
|
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpointValue.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class PlaylistCard extends StatefulWidget {
|
class PlaylistCard extends HookConsumerWidget {
|
||||||
final PlaylistSimple playlist;
|
final PlaylistSimple playlist;
|
||||||
const PlaylistCard(this.playlist, {Key? key}) : super(key: key);
|
const PlaylistCard(this.playlist, {Key? key}) : super(key: key);
|
||||||
@override
|
@override
|
||||||
_PlaylistCardState createState() => _PlaylistCardState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
|
||||||
class _PlaylistCardState extends State<PlaylistCard> {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Playback playback = context.watch<Playback>();
|
|
||||||
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
bool isPlaylistPlaying = playback.currentPlaylist != null &&
|
||||||
playback.currentPlaylist!.id == widget.playlist.id;
|
playback.currentPlaylist!.id == playlist.id;
|
||||||
|
|
||||||
|
final int marginH =
|
||||||
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
title: widget.playlist.name!,
|
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||||
imageUrl: widget.playlist.images![0].url!,
|
title: playlist.name!,
|
||||||
|
imageUrl: playlist.images![0].url!,
|
||||||
isPlaying: isPlaylistPlaying,
|
isPlaying: isPlaylistPlaying,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
GoRouter.of(context).push(
|
||||||
builder: (context) {
|
"/playlist/${playlist.id}",
|
||||||
return PlaylistView(widget.playlist);
|
extra: playlist,
|
||||||
},
|
);
|
||||||
));
|
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
if (isPlaylistPlaying) return;
|
if (isPlaylistPlaying) return;
|
||||||
SpotifyDI data = context.read<SpotifyDI>();
|
SpotifyApi spotifyApi = ref.read(spotifyProvider);
|
||||||
|
|
||||||
List<Track> tracks = (widget.playlist.id != "user-liked-tracks"
|
List<Track> tracks = (playlist.id != "user-liked-tracks"
|
||||||
? await data.spotifyApi.playlists
|
? await spotifyApi.playlists
|
||||||
.getTracksByPlaylistId(widget.playlist.id!)
|
.getTracksByPlaylistId(playlist.id!)
|
||||||
.all()
|
.all()
|
||||||
: await data.spotifyApi.tracks.me.saved
|
: await spotifyApi.tracks.me.saved
|
||||||
.all()
|
.all()
|
||||||
.then((tracks) => tracks.map((e) => e.track!)))
|
.then((tracks) => tracks.map((e) => e.track!)))
|
||||||
.toList();
|
.toList();
|
||||||
@ -48,11 +49,12 @@ class _PlaylistCardState extends State<PlaylistCard> {
|
|||||||
|
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
playback.setCurrentPlaylist = CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: widget.playlist.id!,
|
id: playlist.id!,
|
||||||
name: widget.playlist.name!,
|
name: playlist.name!,
|
||||||
thumbnail: imageToUrlString(widget.playlist.images),
|
thumbnail: imageToUrlString(playlist.images),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = tracks.first;
|
playback.setCurrentTrack = tracks.first;
|
||||||
|
await playback.startPlaying();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
import 'package:spotube/components/Playlist/PlaylistCard.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class PlaylistGenreView extends StatefulWidget {
|
class PlaylistGenreView extends ConsumerWidget {
|
||||||
final String genreId;
|
final String genreId;
|
||||||
final String genreName;
|
final String genreName;
|
||||||
final Iterable<PlaylistSimple>? playlists;
|
final Iterable<PlaylistSimple>? playlists;
|
||||||
@ -15,13 +15,9 @@ class PlaylistGenreView extends StatefulWidget {
|
|||||||
this.playlists,
|
this.playlists,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@override
|
|
||||||
_PlaylistGenreViewState createState() => _PlaylistGenreViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlaylistGenreViewState extends State<PlaylistGenreView> {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const PageWindowTitleBar(
|
appBar: const PageWindowTitleBar(
|
||||||
leading: BackButton(),
|
leading: BackButton(),
|
||||||
@ -29,43 +25,46 @@ class _PlaylistGenreViewState extends State<PlaylistGenreView> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.genreName,
|
genreName,
|
||||||
style: Theme.of(context).textTheme.headline4,
|
style: Theme.of(context).textTheme.headline4,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
Consumer<SpotifyDI>(
|
Consumer(
|
||||||
builder: (context, data, child) => Expanded(
|
builder: (context, ref, child) {
|
||||||
child: SingleChildScrollView(
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
child: FutureBuilder<Iterable<PlaylistSimple>>(
|
return Expanded(
|
||||||
future: widget.playlists == null
|
child: SingleChildScrollView(
|
||||||
? (widget.genreId != "user-featured-playlists"
|
child: FutureBuilder<Iterable<PlaylistSimple>>(
|
||||||
? data.spotifyApi.playlists
|
future: playlists == null
|
||||||
.getByCategoryId(widget.genreId)
|
? (genreId != "user-featured-playlists"
|
||||||
.all()
|
? spotifyApi.playlists
|
||||||
: data.spotifyApi.playlists.featured.all())
|
.getByCategoryId(genreId)
|
||||||
: Future.value(widget.playlists),
|
.all()
|
||||||
builder: (context, snapshot) {
|
: spotifyApi.playlists.featured.all())
|
||||||
if (snapshot.hasError) {
|
: Future.value(playlists),
|
||||||
return const Center(child: Text("Error occurred"));
|
builder: (context, snapshot) {
|
||||||
}
|
if (snapshot.hasError) {
|
||||||
if (!snapshot.hasData) {
|
return const Center(child: Text("Error occurred"));
|
||||||
return const CircularProgressIndicator.adaptive();
|
}
|
||||||
}
|
if (!snapshot.hasData) {
|
||||||
return Center(
|
return const CircularProgressIndicator.adaptive();
|
||||||
child: Wrap(
|
}
|
||||||
children: snapshot.data!
|
return Center(
|
||||||
.map(
|
child: Wrap(
|
||||||
(playlist) => Padding(
|
children: snapshot.data!
|
||||||
padding: const EdgeInsets.all(8.0),
|
.map(
|
||||||
child: PlaylistCard(playlist),
|
(playlist) => Padding(
|
||||||
),
|
padding: const EdgeInsets.all(8.0),
|
||||||
)
|
child: PlaylistCard(playlist),
|
||||||
.toList(),
|
),
|
||||||
),
|
)
|
||||||
);
|
.toList(),
|
||||||
}),
|
),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -1,30 +1,27 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/components/Shared/TracksTableView.dart';
|
import 'package:spotube/components/Shared/TracksTableView.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class PlaylistView extends StatefulWidget {
|
class PlaylistView extends ConsumerWidget {
|
||||||
final PlaylistSimple playlist;
|
final PlaylistSimple playlist;
|
||||||
const PlaylistView(this.playlist, {Key? key}) : super(key: key);
|
const PlaylistView(this.playlist, {Key? key}) : super(key: key);
|
||||||
@override
|
|
||||||
_PlaylistViewState createState() => _PlaylistViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlaylistViewState extends State<PlaylistView> {
|
playPlaylist(Playback playback, List<Track> tracks,
|
||||||
playPlaylist(Playback playback, List<Track> tracks, {Track? currentTrack}) {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
||||||
playback.currentPlaylist?.id == widget.playlist.id;
|
playback.currentPlaylist?.id == playlist.id;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playback.setCurrentPlaylist = CurrentPlaylist(
|
playback.setCurrentPlaylist = CurrentPlaylist(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
id: widget.playlist.id!,
|
id: playlist.id!,
|
||||||
name: widget.playlist.name!,
|
name: playlist.name!,
|
||||||
thumbnail: imageToUrlString(widget.playlist.images),
|
thumbnail: imageToUrlString(playlist.images),
|
||||||
);
|
);
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
@ -32,21 +29,21 @@ class _PlaylistViewState extends State<PlaylistView> {
|
|||||||
currentTrack.id != playback.currentTrack?.id) {
|
currentTrack.id != playback.currentTrack?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
}
|
}
|
||||||
|
await playback.startPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = context.watch<Playback>();
|
Playback playback = ref.watch(playbackProvider);
|
||||||
|
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
|
||||||
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
|
||||||
playback.currentPlaylist?.id == widget.playlist.id;
|
playback.currentPlaylist?.id == playlist.id;
|
||||||
return Consumer<SpotifyDI>(builder: (_, data, __) {
|
return SafeArea(
|
||||||
return Scaffold(
|
child: Scaffold(
|
||||||
body: FutureBuilder<Iterable<Track>>(
|
body: FutureBuilder<Iterable<Track>>(
|
||||||
future: widget.playlist.id != "user-liked-tracks"
|
future: playlist.id != "user-liked-tracks"
|
||||||
? data.spotifyApi.playlists
|
? spotifyApi.playlists.getTracksByPlaylistId(playlist.id).all()
|
||||||
.getTracksByPlaylistId(widget.playlist.id)
|
: spotifyApi.tracks.me.saved
|
||||||
.all()
|
|
||||||
: data.spotifyApi.tracks.me.saved
|
|
||||||
.all()
|
.all()
|
||||||
.then((tracks) => tracks.map((e) => e.track!)),
|
.then((tracks) => tracks.map((e) => e.track!)),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@ -78,7 +75,7 @@ class _PlaylistViewState extends State<PlaylistView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Text(widget.playlist.name!,
|
child: Text(playlist.name!,
|
||||||
style: Theme.of(context).textTheme.headline4),
|
style: Theme.of(context).textTheme.headline4),
|
||||||
),
|
),
|
||||||
snapshot.hasError
|
snapshot.hasError
|
||||||
@ -100,7 +97,7 @@ class _PlaylistViewState extends State<PlaylistView> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart' hide Page;
|
import 'package:flutter/material.dart' hide Page;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Album/AlbumCard.dart';
|
import 'package:spotube/components/Album/AlbumCard.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistCard.dart';
|
import 'package:spotube/components/Artist/ArtistCard.dart';
|
||||||
@ -11,27 +12,14 @@ import 'package:spotube/helpers/zero-pad-num-str.dart';
|
|||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
|
||||||
class Search extends StatefulWidget {
|
class Search extends HookConsumerWidget {
|
||||||
const Search({Key? key}) : super(key: key);
|
const Search({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Search> createState() => _SearchState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
|
var controller = useTextEditingController();
|
||||||
class _SearchState extends State<Search> {
|
var searchTerm = useState("");
|
||||||
late TextEditingController _controller;
|
|
||||||
|
|
||||||
String searchTerm = "";
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = TextEditingController();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
SpotifyApi spotify = context.watch<SpotifyDI>().spotifyApi;
|
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -43,11 +31,9 @@ class _SearchState extends State<Search> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
decoration: const InputDecoration(hintText: "Search..."),
|
decoration: const InputDecoration(hintText: "Search..."),
|
||||||
controller: _controller,
|
controller: controller,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
setState(() {
|
searchTerm.value = controller.value.text;
|
||||||
searchTerm = _controller.value.text;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -60,27 +46,25 @@ class _SearchState extends State<Search> {
|
|||||||
textColor: Colors.white,
|
textColor: Colors.white,
|
||||||
child: const Icon(Icons.search_rounded),
|
child: const Icon(Icons.search_rounded),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
searchTerm.value = controller.value.text;
|
||||||
searchTerm = _controller.value.text;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FutureBuilder<List<Page>>(
|
FutureBuilder<List<Page>>(
|
||||||
future: searchTerm.isNotEmpty
|
future: searchTerm.value.isNotEmpty
|
||||||
? spotify.search.get(searchTerm).first(10)
|
? spotify.search.get(searchTerm.value).first(10)
|
||||||
: null,
|
: null,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData && searchTerm.isNotEmpty) {
|
if (!snapshot.hasData && searchTerm.value.isNotEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator.adaptive(),
|
child: CircularProgressIndicator.adaptive(),
|
||||||
);
|
);
|
||||||
} else if (!snapshot.hasData && searchTerm.isEmpty) {
|
} else if (!snapshot.hasData && searchTerm.value.isEmpty) {
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
Playback playback = context.watch<Playback>();
|
Playback playback = ref.watch(playbackProvider);
|
||||||
List<AlbumSimple> albums = [];
|
List<AlbumSimple> albums = [];
|
||||||
List<Artist> artists = [];
|
List<Artist> artists = [];
|
||||||
List<Track> tracks = [];
|
List<Track> tracks = [];
|
||||||
@ -115,8 +99,7 @@ class _SearchState extends State<Search> {
|
|||||||
...tracks.asMap().entries.map((track) {
|
...tracks.asMap().entries.map((track) {
|
||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return TracksTableView.buildTrackTile(
|
return TrackTile(
|
||||||
context,
|
|
||||||
playback,
|
playback,
|
||||||
track: track,
|
track: track,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
@ -142,6 +125,7 @@ class _SearchState extends State<Search> {
|
|||||||
playback.currentTrack?.id) {
|
playback.currentTrack?.id) {
|
||||||
playback.setCurrentTrack = currentTrack;
|
playback.setCurrentTrack = currentTrack;
|
||||||
}
|
}
|
||||||
|
await playback.startPlaying();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -1,213 +1,202 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
|
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
|
||||||
import 'package:spotube/components/Shared/Hyperlink.dart';
|
import 'package:spotube/components/Shared/Hyperlink.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/main.dart';
|
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
|
import 'package:spotube/provider/ThemeProvider.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
|
|
||||||
class Settings extends StatefulWidget {
|
class Settings extends HookConsumerWidget {
|
||||||
const Settings({Key? key}) : super(key: key);
|
const Settings({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SettingsState createState() => _SettingsState();
|
Widget build(BuildContext context, ref) {
|
||||||
}
|
UserPreferences preferences = ref.watch(userPreferencesProvider);
|
||||||
|
ThemeMode theme = ref.watch(themeProvider);
|
||||||
|
var geniusAccessToken = useState<String?>(null);
|
||||||
|
TextEditingController textEditingController = useTextEditingController();
|
||||||
|
|
||||||
class _SettingsState extends State<Settings> {
|
textEditingController.addListener(() {
|
||||||
TextEditingController? _textEditingController;
|
geniusAccessToken.value = textEditingController.value.text;
|
||||||
String? _geniusAccessToken;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_textEditingController = TextEditingController();
|
|
||||||
_textEditingController?.addListener(() {
|
|
||||||
setState(() {
|
|
||||||
_geniusAccessToken = _textEditingController?.value.text;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
return SafeArea(
|
||||||
void dispose() {
|
child: Scaffold(
|
||||||
_textEditingController?.dispose();
|
appBar: PageWindowTitleBar(
|
||||||
super.dispose();
|
leading: const BackButton(),
|
||||||
}
|
center: Text(
|
||||||
|
"Settings",
|
||||||
@override
|
style: Theme.of(context).textTheme.headline5,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
body: Padding(
|
||||||
body: Padding(
|
padding: const EdgeInsets.all(16.0),
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Expanded(
|
flex: 2,
|
||||||
flex: 2,
|
child: Text(
|
||||||
child: Text(
|
"Genius Access Token",
|
||||||
"Genius Access Token",
|
style: Theme.of(context).textTheme.subtitle1,
|
||||||
style: Theme.of(context).textTheme.subtitle1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: TextField(
|
|
||||||
controller: _textEditingController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: preferences.geniusAccessToken,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Padding(
|
flex: 1,
|
||||||
padding: const EdgeInsets.all(8.0),
|
child: TextField(
|
||||||
child: ElevatedButton(
|
controller: textEditingController,
|
||||||
onPressed: _geniusAccessToken != null
|
decoration: InputDecoration(
|
||||||
? () async {
|
hintText: preferences.geniusAccessToken,
|
||||||
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",
|
|
||||||
),
|
),
|
||||||
value: ThemeMode.dark,
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
),
|
||||||
child: Text(
|
Padding(
|
||||||
"Light",
|
padding: const EdgeInsets.all(8.0),
|
||||||
),
|
child: ElevatedButton(
|
||||||
value: ThemeMode.light,
|
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,
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
],
|
if (!Platform.isAndroid && !Platform.isIOS) ...[
|
||||||
onChanged: (value) {
|
SettingsHotKeyTile(
|
||||||
if (value != null) {
|
title: "Next track global shortcut",
|
||||||
MyApp.of(context)?.setThemeMode(value);
|
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(
|
||||||
const SizedBox(height: 10),
|
|
||||||
Builder(builder: (context) {
|
|
||||||
var auth = context.read<Auth>();
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text("Log out of this account"),
|
const Text("Theme"),
|
||||||
ElevatedButton(
|
DropdownButton<ThemeMode>(
|
||||||
child: const Text("Logout"),
|
value: theme,
|
||||||
style: ButtonStyle(
|
items: const [
|
||||||
backgroundColor: MaterialStateProperty.all(Colors.red),
|
DropdownMenuItem(
|
||||||
),
|
child: Text(
|
||||||
onPressed: () async {
|
"Dark",
|
||||||
SharedPreferences localStorage =
|
),
|
||||||
await SharedPreferences.getInstance();
|
value: ThemeMode.dark,
|
||||||
await localStorage.clear();
|
),
|
||||||
auth.logout();
|
DropdownMenuItem(
|
||||||
Navigator.of(context).pop();
|
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: 20),
|
||||||
const SizedBox(height: 40),
|
Wrap(
|
||||||
const Text("Spotube v1.2.0"),
|
alignment: WrapAlignment.center,
|
||||||
const SizedBox(height: 10),
|
children: const [
|
||||||
Row(
|
Hyperlink(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
"💚 Sponsor/Donate 💚",
|
||||||
children: const [
|
"https://opencollective.com/spotube",
|
||||||
Text("Author: "),
|
),
|
||||||
Hyperlink(
|
Text(" • "),
|
||||||
"Kingkor Roy Tirtho",
|
Hyperlink(
|
||||||
"https://github.com/KRTirtho",
|
"BSD-4-Clause LICENSE",
|
||||||
),
|
"https://github.com/KRTirtho/spotube/blob/master/LICENSE",
|
||||||
],
|
),
|
||||||
),
|
Text(" • "),
|
||||||
const SizedBox(height: 20),
|
Hyperlink(
|
||||||
Row(
|
"Bug Report",
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
"https://github.com/KRTirtho/spotube/issues/new?assignees=&labels=bug&template=bug_report.md&title=",
|
||||||
children: const [
|
),
|
||||||
Hyperlink(
|
],
|
||||||
"💚 Sponsor/Donate 💚",
|
),
|
||||||
"https://opencollective.com/spotube",
|
const SizedBox(height: 10),
|
||||||
),
|
const Text("© Spotube 2022. All rights reserved")
|
||||||
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")
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
class AnchorButton<T> extends StatefulWidget {
|
class AnchorButton<T> extends HookWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final TextStyle style;
|
final TextStyle style;
|
||||||
final TextAlign? textAlign;
|
final TextAlign? textAlign;
|
||||||
@ -16,33 +17,29 @@ class AnchorButton<T> extends StatefulWidget {
|
|||||||
this.style = const TextStyle(),
|
this.style = const TextStyle(),
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
State<AnchorButton<T>> createState() => _AnchorButtonState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AnchorButtonState<T> extends State<AnchorButton<T>> {
|
|
||||||
bool _hover = false;
|
|
||||||
bool _tap = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var hover = useState(false);
|
||||||
|
var tap = useState(false);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: MaterialStateMouseCursor.clickable,
|
cursor: MaterialStateMouseCursor.clickable,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.text,
|
text,
|
||||||
style: widget.style.copyWith(
|
style: style.copyWith(
|
||||||
decoration: _hover || _tap ? TextDecoration.underline : null,
|
decoration:
|
||||||
|
hover.value || tap.value ? TextDecoration.underline : null,
|
||||||
),
|
),
|
||||||
textAlign: widget.textAlign,
|
textAlign: textAlign,
|
||||||
overflow: widget.overflow,
|
overflow: overflow,
|
||||||
),
|
),
|
||||||
onEnter: (event) => setState(() => _hover = true),
|
onEnter: (event) => hover.value = true,
|
||||||
onExit: (event) => setState(() => _hover = false),
|
onExit: (event) => hover.value = false,
|
||||||
),
|
),
|
||||||
onTapDown: (event) => setState(() => _tap = true),
|
onTapDown: (event) => tap.value = true,
|
||||||
onTapUp: (event) => setState(() => _tap = false),
|
onTapUp: (event) => tap.value = false,
|
||||||
onTap: widget.onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,106 +1,86 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/helpers/artist-to-string.dart';
|
import 'package:spotube/helpers/artist-to-string.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
import 'package:path_provider/path_provider.dart' as path_provider;
|
import 'package:path_provider/path_provider.dart' as path_provider;
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
class DownloadTrackButton extends StatefulWidget {
|
enum TrackStatus { downloading, idle, done }
|
||||||
|
|
||||||
|
class DownloadTrackButton extends HookWidget {
|
||||||
final Track? track;
|
final Track? track;
|
||||||
const DownloadTrackButton({Key? key, this.track}) : super(key: key);
|
const DownloadTrackButton({Key? key, this.track}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_DownloadTrackButtonState createState() => _DownloadTrackButtonState();
|
Widget build(BuildContext context) {
|
||||||
}
|
var status = useState<TrackStatus>(TrackStatus.idle);
|
||||||
|
YoutubeExplode yt = useMemoized(() => YoutubeExplode());
|
||||||
|
|
||||||
enum TrackStatus { downloading, idle, done }
|
var _downloadTrack = useCallback(() async {
|
||||||
|
if (track == null) return;
|
||||||
|
StreamManifest manifest =
|
||||||
|
await yt.videos.streamsClient.getManifest(track?.href);
|
||||||
|
|
||||||
class _DownloadTrackButtonState extends State<DownloadTrackButton> {
|
var audioStream = yt.videos.streamsClient.get(
|
||||||
late YoutubeExplode yt;
|
manifest.audioOnly
|
||||||
TrackStatus status = TrackStatus.idle;
|
.where((audio) => audio.codec.mimeType == "audio/mp4")
|
||||||
|
.withHighestBitrate(),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
var statusCb = audioStream.listen(
|
||||||
void initState() {
|
(event) {
|
||||||
yt = YoutubeExplode();
|
if (status.value != TrackStatus.downloading) {
|
||||||
super.initState();
|
status.value = TrackStatus.downloading;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
@override
|
onDone: () async {
|
||||||
void dispose() {
|
status.value = TrackStatus.done;
|
||||||
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;
|
|
||||||
});
|
|
||||||
await Future.delayed(
|
await Future.delayed(
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() {
|
() {
|
||||||
if (status == TrackStatus.done) {
|
if (status.value == TrackStatus.done) {
|
||||||
setState(() {
|
status.value = TrackStatus.idle;
|
||||||
status = TrackStatus.idle;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return statusCb.cancel();
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
String downloadFolder = path.join(
|
||||||
Widget build(BuildContext context) {
|
(await path_provider.getDownloadsDirectory())!.path, "Spotube");
|
||||||
if (status == TrackStatus.downloading) {
|
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(
|
return const SizedBox(
|
||||||
child: CircularProgressIndicator.adaptive(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
@ -108,13 +88,13 @@ class _DownloadTrackButtonState extends State<DownloadTrackButton> {
|
|||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
);
|
);
|
||||||
} else if (status == TrackStatus.done) {
|
} else if (status.value == TrackStatus.done) {
|
||||||
return const Icon(Icons.download_done_rounded);
|
return const Icon(Icons.download_done_rounded);
|
||||||
}
|
}
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.download_rounded),
|
icon: const Icon(Icons.download_rounded),
|
||||||
onPressed: widget.track != null &&
|
onPressed: track != null &&
|
||||||
!(widget.track!.href ?? "").startsWith("https://api.spotify.com")
|
!(track!.href ?? "").startsWith("https://api.spotify.com")
|
||||||
? _downloadTrack
|
? _downloadTrack
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotube/components/Shared/AnchorButton.dart';
|
import 'package:spotube/components/Shared/AnchorButton.dart';
|
||||||
|
|
||||||
class LinkText<T> extends StatelessWidget {
|
class LinkText<T> extends StatelessWidget {
|
||||||
@ -6,12 +7,14 @@ class LinkText<T> extends StatelessWidget {
|
|||||||
final TextStyle style;
|
final TextStyle style;
|
||||||
final TextAlign? textAlign;
|
final TextAlign? textAlign;
|
||||||
final TextOverflow? overflow;
|
final TextOverflow? overflow;
|
||||||
final Route<T> route;
|
final String route;
|
||||||
|
final T? extra;
|
||||||
const LinkText(
|
const LinkText(
|
||||||
this.text,
|
this.text,
|
||||||
this.route, {
|
this.route, {
|
||||||
Key? key,
|
Key? key,
|
||||||
this.textAlign,
|
this.textAlign,
|
||||||
|
this.extra,
|
||||||
this.overflow,
|
this.overflow,
|
||||||
this.style = const TextStyle(),
|
this.style = const TextStyle(),
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@ -20,8 +23,8 @@ class LinkText<T> extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnchorButton(
|
return AnchorButton(
|
||||||
text,
|
text,
|
||||||
onTap: () async {
|
onTap: () {
|
||||||
await Navigator.of(context).push(route);
|
GoRouter.of(context).push(route, extra: extra);
|
||||||
},
|
},
|
||||||
key: key,
|
key: key,
|
||||||
overflow: overflow,
|
overflow: overflow,
|
||||||
|
@ -52,10 +52,23 @@ class PageWindowTitleBar extends StatelessWidget
|
|||||||
const PageWindowTitleBar({Key? key, this.leading, this.center})
|
const PageWindowTitleBar({Key? key, this.leading, this.center})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => Size.fromHeight(appWindow.titleBarHeight);
|
Size get preferredSize => Size.fromHeight(
|
||||||
|
!Platform.isIOS && !Platform.isAndroid ? appWindow.titleBarHeight : 35,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS || Platform.isAndroid) {
|
||||||
|
return PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(300),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (leading != null) leading!,
|
||||||
|
Expanded(child: Center(child: center)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return WindowTitleBarBox(
|
return WindowTitleBarBox(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -65,7 +78,8 @@ class PageWindowTitleBar extends StatelessWidget
|
|||||||
),
|
),
|
||||||
if (leading != null) leading!,
|
if (leading != null) leading!,
|
||||||
Expanded(child: MoveWindow(child: Center(child: center))),
|
Expanded(child: MoveWindow(child: Center(child: center))),
|
||||||
if (!Platform.isMacOS) const TitleBarActionButtons()
|
if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid)
|
||||||
|
const TitleBarActionButtons()
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,7 @@ class PlaybuttonCard extends StatelessWidget {
|
|||||||
final void Function()? onTap;
|
final void Function()? onTap;
|
||||||
final void Function()? onPlaybuttonPressed;
|
final void Function()? onPlaybuttonPressed;
|
||||||
final String? description;
|
final String? description;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
final bool isPlaying;
|
final bool isPlaying;
|
||||||
final String title;
|
final String title;
|
||||||
@ -12,6 +13,7 @@ class PlaybuttonCard extends StatelessWidget {
|
|||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
required this.isPlaying,
|
required this.isPlaying,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
this.margin,
|
||||||
this.description,
|
this.description,
|
||||||
this.onPlaybuttonPressed,
|
this.onPlaybuttonPressed,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
@ -20,88 +22,92 @@ class PlaybuttonCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Container(
|
||||||
onTap: onTap,
|
margin: margin,
|
||||||
child: ConstrainedBox(
|
child: InkWell(
|
||||||
constraints: const BoxConstraints(maxWidth: 200),
|
onTap: onTap,
|
||||||
child: Ink(
|
child: ConstrainedBox(
|
||||||
decoration: BoxDecoration(
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
color: Theme.of(context).backgroundColor,
|
child: Ink(
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
boxShadow: [
|
color: Theme.of(context).backgroundColor,
|
||||||
BoxShadow(
|
borderRadius: BorderRadius.circular(8),
|
||||||
blurRadius: 10,
|
boxShadow: [
|
||||||
offset: const Offset(0, 3),
|
BoxShadow(
|
||||||
spreadRadius: 5,
|
blurRadius: 10,
|
||||||
color: Theme.of(context).shadowColor)
|
offset: const Offset(0, 3),
|
||||||
],
|
spreadRadius: 5,
|
||||||
),
|
color: Theme.of(context).shadowColor)
|
||||||
child: Column(
|
],
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
// thumbnail of the playlist
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Stack(
|
children: [
|
||||||
children: [
|
// thumbnail of the playlist
|
||||||
ClipRRect(
|
Stack(
|
||||||
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(
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
ClipRRect(
|
||||||
title,
|
borderRadius: BorderRadius.circular(8),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
placeholder: (context, url) =>
|
||||||
|
Image.asset("assets/placeholder.png"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (description != null) ...[
|
Positioned.directional(
|
||||||
const SizedBox(height: 10),
|
textDirection: TextDirection.ltr,
|
||||||
Text(
|
bottom: 10,
|
||||||
description!,
|
end: 5,
|
||||||
style: TextStyle(
|
child: Builder(builder: (context) {
|
||||||
fontSize: 13,
|
return ElevatedButton(
|
||||||
color: Theme.of(context).textTheme.headline4?.color,
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||||
|
|
||||||
class RecordHotKeyDialog extends StatefulWidget {
|
class RecordHotKeyDialog extends HookWidget {
|
||||||
final ValueChanged<HotKey> onHotKeyRecorded;
|
final ValueChanged<HotKey> onHotKeyRecorded;
|
||||||
|
|
||||||
const RecordHotKeyDialog({
|
const RecordHotKeyDialog({
|
||||||
@ -9,15 +11,9 @@ class RecordHotKeyDialog extends StatefulWidget {
|
|||||||
required this.onHotKeyRecorded,
|
required this.onHotKeyRecorded,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
_RecordHotKeyDialogState createState() => _RecordHotKeyDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
|
|
||||||
HotKey _hotKey = HotKey(null);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var _hotKey = useState(HotKey(null));
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: ListBody(
|
child: ListBody(
|
||||||
@ -58,9 +54,7 @@ class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
|
|||||||
children: [
|
children: [
|
||||||
HotKeyRecorder(
|
HotKeyRecorder(
|
||||||
onHotKeyRecorded: (hotKey) {
|
onHotKeyRecorded: (hotKey) {
|
||||||
setState(() {
|
_hotKey.value = hotKey;
|
||||||
_hotKey = hotKey;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -73,16 +67,16 @@ class _RecordHotKeyDialogState extends State<RecordHotKeyDialog> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
GoRouter.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('OK'),
|
child: const Text('OK'),
|
||||||
onPressed: !_hotKey.isSetted
|
onPressed: !_hotKey.value.isSetted
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
widget.onHotKeyRecorded(_hotKey);
|
onHotKeyRecorded(_hotKey.value);
|
||||||
Navigator.of(context).pop();
|
GoRouter.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
36
lib/components/Shared/SpotubePageRoute.dart
Normal file
36
lib/components/Shared/SpotubePageRoute.dart
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -1,106 +1,31 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Album/AlbumView.dart';
|
import 'package:spotube/components/Album/AlbumView.dart';
|
||||||
import 'package:spotube/components/Shared/LinkText.dart';
|
import 'package:spotube/components/Shared/LinkText.dart';
|
||||||
|
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
||||||
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
|
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
|
||||||
import 'package:spotube/helpers/image-to-url-string.dart';
|
import 'package:spotube/helpers/image-to-url-string.dart';
|
||||||
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
import 'package:spotube/helpers/zero-pad-num-str.dart';
|
||||||
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
|
|
||||||
class TracksTableView extends StatelessWidget {
|
class TracksTableView extends HookConsumerWidget {
|
||||||
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed})
|
const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
static Widget buildTrackTile(
|
|
||||||
BuildContext context,
|
|
||||||
Playback playback, {
|
|
||||||
required MapEntry<int, Track> track,
|
|
||||||
required String duration,
|
|
||||||
String? thumbnailUrl,
|
|
||||||
final void Function(Track currentTrack)? onTrackPlayButtonPressed,
|
|
||||||
}) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 25,
|
|
||||||
child: Text(
|
|
||||||
(track.key + 1).toString(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (thumbnailUrl != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
placeholder: (context, url) {
|
|
||||||
return Container(
|
|
||||||
height: 40,
|
|
||||||
width: 40,
|
|
||||||
color: Colors.green[300],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
imageUrl: thumbnailUrl,
|
|
||||||
maxHeightDiskCache: 40,
|
|
||||||
maxWidthDiskCache: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
playback.currentTrack?.id != null &&
|
|
||||||
playback.currentTrack?.id == track.value.id
|
|
||||||
? Icons.pause_circle_rounded
|
|
||||||
: Icons.play_circle_rounded,
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
onPressed: () => onTrackPlayButtonPressed?.call(
|
|
||||||
track.value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
track.value.name ?? "",
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 17,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
artistsToClickableArtists(track.value.artists ?? []),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinkText(
|
|
||||||
track.value.album!.name!,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumView(track.value.album!),
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(duration),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(context, ref) {
|
||||||
Playback playback = context.watch<Playback>();
|
Playback playback = ref.watch(playbackProvider);
|
||||||
TextStyle tableHeadStyle =
|
TextStyle tableHeadStyle =
|
||||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
||||||
|
|
||||||
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@ -127,21 +52,25 @@ class TracksTableView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// used alignment of this table-head
|
// used alignment of this table-head
|
||||||
const SizedBox(width: 100),
|
if (breakpoint.isMoreThan(Breakpoints.md)) ...[
|
||||||
Expanded(
|
const SizedBox(width: 100),
|
||||||
child: Row(
|
Expanded(
|
||||||
children: [
|
child: Row(
|
||||||
Text(
|
children: [
|
||||||
"Album",
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
"Album",
|
||||||
style: tableHeadStyle,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: tableHeadStyle,
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
)
|
||||||
Text("Time", style: tableHeadStyle),
|
],
|
||||||
const SizedBox(width: 10),
|
if (!breakpoint.isSm) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text("Time", style: tableHeadStyle),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
]
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
...tracks.asMap().entries.map((track) {
|
...tracks.asMap().entries.map((track) {
|
||||||
@ -151,11 +80,13 @@ class TracksTableView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return buildTrackTile(context, playback,
|
return TrackTile(
|
||||||
track: track,
|
playback,
|
||||||
duration: duration,
|
track: track,
|
||||||
thumbnailUrl: thumbnailUrl,
|
duration: duration,
|
||||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed);
|
thumbnailUrl: thumbnailUrl,
|
||||||
|
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||||
|
);
|
||||||
}).toList()
|
}).toList()
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -163,3 +94,103 @@ class TracksTableView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TrackTile extends HookWidget {
|
||||||
|
final Playback playback;
|
||||||
|
final MapEntry<int, Track> track;
|
||||||
|
final String duration;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
||||||
|
const TrackTile(
|
||||||
|
this.playback, {
|
||||||
|
required this.track,
|
||||||
|
required this.duration,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.onTrackPlayButtonPressed,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final breakpoint = useBreakpoints();
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 25,
|
||||||
|
child: Text(
|
||||||
|
(track.key + 1).toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (thumbnailUrl != null)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: breakpoint.isMoreThan(Breakpoints.md) ? 8.0 : 0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
placeholder: (context, url) {
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
color: Colors.green[300],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
imageUrl: thumbnailUrl!,
|
||||||
|
maxHeightDiskCache: 40,
|
||||||
|
maxWidthDiskCache: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
playback.currentTrack?.id != null &&
|
||||||
|
playback.currentTrack?.id == track.value.id
|
||||||
|
? Icons.pause_circle_rounded
|
||||||
|
: Icons.play_circle_rounded,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
onPressed: () => onTrackPlayButtonPressed?.call(
|
||||||
|
track.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
track.value.name ?? "",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: breakpoint.isSm ? 14 : 17,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
artistsToClickableArtists(track.value.artists ?? [],
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize:
|
||||||
|
breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (breakpoint.isMoreThan(Breakpoints.md))
|
||||||
|
Expanded(
|
||||||
|
child: LinkText(
|
||||||
|
track.value.album!.name!,
|
||||||
|
"/album/${track.value.album?.id}",
|
||||||
|
extra: track.value.album,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!breakpoint.isSm) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(duration),
|
||||||
|
const SizedBox(width: 10)
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistProfile.dart';
|
|
||||||
import 'package:spotube/components/Shared/LinkText.dart';
|
import 'package:spotube/components/Shared/LinkText.dart';
|
||||||
|
|
||||||
Widget artistsToClickableArtists(
|
Widget artistsToClickableArtists(
|
||||||
List<ArtistSimple> artists, {
|
List<ArtistSimple> artists, {
|
||||||
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
|
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
|
||||||
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
|
WrapAlignment mainAxisAlignment = WrapAlignment.center,
|
||||||
|
TextStyle textStyle = const TextStyle(),
|
||||||
}) {
|
}) {
|
||||||
return Row(
|
return Wrap(
|
||||||
crossAxisAlignment: crossAxisAlignment,
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
mainAxisAlignment: mainAxisAlignment,
|
alignment: mainAxisAlignment,
|
||||||
children: artists
|
children: artists
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
@ -19,10 +19,9 @@ Widget artistsToClickableArtists(
|
|||||||
(artist.key != artists.length - 1)
|
(artist.key != artists.length - 1)
|
||||||
? "${artist.value.name}, "
|
? "${artist.value.name}, "
|
||||||
: artist.value.name!,
|
: artist.value.name!,
|
||||||
MaterialPageRoute<ArtistProfile>(
|
"/artist/${artist.value.id}",
|
||||||
builder: (context) => ArtistProfile(artist.value.id!),
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:uuid/uuid.dart' show Uuid;
|
||||||
|
|
||||||
|
const uuid = Uuid();
|
||||||
String imageToUrlString(List<Image>? images, {int index = 0}) {
|
String imageToUrlString(List<Image>? images, {int index = 0}) {
|
||||||
return images != null && images.isNotEmpty
|
return images != null && images.isNotEmpty
|
||||||
? images[0].url!
|
? images[0].url!
|
||||||
: "https://avatars.dicebear.com/api/croodles-neutral/${DateTime.now().toString()}.png";
|
: "https://avatars.dicebear.com/api/bottts/${uuid.v4()}.png";
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Home.dart';
|
import 'package:spotube/components/Home/Home.dart';
|
||||||
import 'package:spotube/helpers/server_ipc.dart';
|
import 'package:spotube/helpers/server_ipc.dart';
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
|
|
||||||
const redirectUri = "http://localhost:4304/auth/spotify/callback";
|
const redirectUri = "http://localhost:4304/auth/spotify/callback";
|
||||||
|
|
||||||
Future<void> oauthLogin(BuildContext context,
|
Future<void> oauthLogin(Auth auth,
|
||||||
{required String clientId, required String clientSecret}) async {
|
{required String clientId, required String clientSecret}) async {
|
||||||
try {
|
try {
|
||||||
String? accessToken;
|
String? accessToken;
|
||||||
@ -50,7 +48,7 @@ Future<void> oauthLogin(BuildContext context,
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
Provider.of<Auth>(context, listen: false).setAuthState(
|
auth.setAuthState(
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
@ -21,7 +23,16 @@ Future<Track> toYoutubeTrack(YoutubeExplode youtube, Track track) async {
|
|||||||
|
|
||||||
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
|
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
|
||||||
|
|
||||||
track.uri = trackManifest.audioOnly.withHighestBitrate().url.toString();
|
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
|
||||||
|
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
||||||
|
// codec/mimetype for those Platforms
|
||||||
|
track.uri = (Platform.isMacOS || Platform.isIOS
|
||||||
|
? trackManifest.audioOnly
|
||||||
|
.where((info) => info.codec.mimeType == "audio/mp4")
|
||||||
|
.withHighestBitrate()
|
||||||
|
: trackManifest.audioOnly.withHighestBitrate())
|
||||||
|
.url
|
||||||
|
.toString();
|
||||||
track.href = ytVideo.url;
|
track.href = ytVideo.url;
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
41
lib/hooks/playback.dart
Normal file
41
lib/hooks/playback.dart
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
18
lib/hooks/useAsyncEffect.dart
Normal file
18
lib/hooks/useAsyncEffect.dart
Normal 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);
|
||||||
|
}
|
17
lib/hooks/useBreakpointValue.dart
Normal file
17
lib/hooks/useBreakpointValue.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
104
lib/hooks/useBreakpoints.dart
Normal file
104
lib/hooks/useBreakpoints.dart
Normal 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
48
lib/hooks/useHotKeys.dart
Normal 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)));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
18
lib/hooks/useIsCurrentRoute.dart
Normal file
18
lib/hooks/useIsCurrentRoute.dart
Normal 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;
|
||||||
|
}
|
53
lib/hooks/usePagingController.dart
Normal file
53
lib/hooks/usePagingController.dart
Normal 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';
|
||||||
|
}
|
33
lib/hooks/usePaletteColor.dart
Normal file
33
lib/hooks/usePaletteColor.dart
Normal 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;
|
||||||
|
}
|
9
lib/hooks/useSharedPreferences.dart
Normal file
9
lib/hooks/useSharedPreferences.dart
Normal 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;
|
||||||
|
}
|
309
lib/main.dart
309
lib/main.dart
@ -1,210 +1,163 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotube/models/GoRouteDeclarations.dart';
|
||||||
import 'package:spotube/components/Home.dart';
|
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/AudioPlayer.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/ThemeProvider.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/YouTube.dart';
|
||||||
import 'package:spotube/provider/UserPreferences.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
await hotKeyManager.unregisterAll();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(MyApp());
|
await hotKeyManager.unregisterAll();
|
||||||
doWhenWindowReady(() {
|
doWhenWindowReady(() {
|
||||||
appWindow.minSize = const Size(900, 700);
|
appWindow.minSize =
|
||||||
appWindow.size = const Size(900, 700);
|
Size(Platform.isAndroid || Platform.isIOS ? 280 : 359, 700);
|
||||||
appWindow.alignment = Alignment.center;
|
appWindow.size = const Size(900, 700);
|
||||||
appWindow.maximize();
|
appWindow.alignment = Alignment.center;
|
||||||
appWindow.show();
|
appWindow.maximize();
|
||||||
});
|
appWindow.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runApp(ProviderScope(child: MyApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends HookConsumerWidget {
|
||||||
static _MyAppState? of(BuildContext context) =>
|
final GoRouter _router = createGoRouter();
|
||||||
context.findAncestorStateOfType<_MyAppState>();
|
|
||||||
|
MyApp({Key? key}) : super(key: key);
|
||||||
@override
|
@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) {
|
switch (themeMode) {
|
||||||
case "light":
|
case "light":
|
||||||
_themeMode = ThemeMode.light;
|
themeNotifier.state = ThemeMode.light;
|
||||||
break;
|
break;
|
||||||
case "dark":
|
case "dark":
|
||||||
_themeMode = ThemeMode.dark;
|
themeNotifier.state = ThemeMode.dark;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
_themeMode = ThemeMode.system;
|
themeNotifier.state = ThemeMode.system;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
return () {
|
||||||
super.initState();
|
player.dispose();
|
||||||
}
|
youtube.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
void setThemeMode(ThemeMode themeMode) {
|
return MaterialApp.router(
|
||||||
SharedPreferences.getInstance().then((localStorage) {
|
routeInformationParser: _router.routeInformationParser,
|
||||||
localStorage.setString(
|
routerDelegate: _router.routerDelegate,
|
||||||
LocalStorageKeys.themeMode, themeMode.toString().split(".").last);
|
debugShowCheckedModeBanner: false,
|
||||||
setState(() {
|
title: 'Spotube',
|
||||||
_themeMode = themeMode;
|
theme: ThemeData(
|
||||||
});
|
primaryColor: Colors.green,
|
||||||
});
|
primarySwatch: Colors.green,
|
||||||
}
|
buttonTheme: const ButtonThemeData(
|
||||||
|
buttonColor: Colors.green,
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
shadowColor: Colors.grey[300],
|
||||||
child: MaterialApp(
|
backgroundColor: Colors.white,
|
||||||
debugShowCheckedModeBanner: false,
|
textTheme: TextTheme(
|
||||||
title: 'Spotube',
|
bodyText1: TextStyle(color: Colors.grey[850]),
|
||||||
theme: ThemeData(
|
headline1: TextStyle(color: Colors.grey[850]),
|
||||||
primaryColor: Colors.green,
|
headline2: TextStyle(color: Colors.grey[850]),
|
||||||
primarySwatch: Colors.green,
|
headline3: TextStyle(color: Colors.grey[850]),
|
||||||
buttonTheme: const ButtonThemeData(
|
headline4: TextStyle(color: Colors.grey[850]),
|
||||||
buttonColor: Colors.green,
|
headline5: TextStyle(color: Colors.grey[850]),
|
||||||
),
|
headline6: TextStyle(color: Colors.grey[850]),
|
||||||
shadowColor: Colors.grey[300],
|
),
|
||||||
backgroundColor: Colors.white,
|
listTileTheme: ListTileThemeData(
|
||||||
textTheme: TextTheme(
|
iconColor: Colors.grey[850],
|
||||||
bodyText1: TextStyle(color: Colors.grey[850]),
|
horizontalTitleGap: 0,
|
||||||
headline1: TextStyle(color: Colors.grey[850]),
|
),
|
||||||
headline2: TextStyle(color: Colors.grey[850]),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
headline3: TextStyle(color: Colors.grey[850]),
|
focusedBorder: OutlineInputBorder(
|
||||||
headline4: TextStyle(color: Colors.grey[850]),
|
borderSide: BorderSide(
|
||||||
headline5: TextStyle(color: Colors.grey[850]),
|
color: Colors.green[400]!,
|
||||||
headline6: TextStyle(color: Colors.grey[850]),
|
width: 2.0,
|
||||||
),
|
|
||||||
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]!,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
enabledBorder: OutlineInputBorder(
|
||||||
backgroundColor: Colors.blueGrey[50],
|
borderSide: BorderSide(
|
||||||
unselectedIconTheme:
|
color: Colors.grey[800]!,
|
||||||
IconThemeData(color: Colors.grey[850], opacity: 1),
|
|
||||||
unselectedLabelTextStyle: TextStyle(
|
|
||||||
color: Colors.grey[850],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
cardTheme: CardTheme(
|
|
||||||
shape:
|
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
brightness: Brightness.dark,
|
backgroundColor: Colors.blueGrey[50],
|
||||||
primaryColor: Colors.green,
|
unselectedIconTheme:
|
||||||
primarySwatch: Colors.green,
|
IconThemeData(color: Colors.grey[850], opacity: 1),
|
||||||
backgroundColor: Colors.blueGrey[900],
|
unselectedLabelTextStyle: TextStyle(
|
||||||
scaffoldBackgroundColor: Colors.blueGrey[900],
|
color: Colors.grey[850],
|
||||||
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),
|
|
||||||
),
|
|
||||||
cardTheme: CardTheme(
|
|
||||||
shape:
|
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
color: Colors.blueGrey[900],
|
|
||||||
elevation: 20,
|
|
||||||
),
|
|
||||||
canvasColor: Colors.blueGrey[900],
|
|
||||||
),
|
),
|
||||||
themeMode: _themeMode,
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
home: const Home(),
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
73
lib/models/GoRouteDeclarations.dart
Normal file
73
lib/models/GoRouteDeclarations.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
@ -10,4 +10,6 @@ abstract class LocalStorageKeys {
|
|||||||
static String nextTrackHotKey = "next_track_hot_key";
|
static String nextTrackHotKey = "next_track_hot_key";
|
||||||
static String prevTrackHotKey = "prev_track_hot_key";
|
static String prevTrackHotKey = "prev_track_hot_key";
|
||||||
static String playPauseHotKey = "play_pause_hot_key";
|
static String playPauseHotKey = "play_pause_hot_key";
|
||||||
|
|
||||||
|
static String volume = "volume";
|
||||||
}
|
}
|
||||||
|
6
lib/provider/AudioPlayer.dart
Normal file
6
lib/provider/AudioPlayer.dart
Normal 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();
|
||||||
|
});
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
class Auth with ChangeNotifier {
|
class Auth with ChangeNotifier {
|
||||||
String? _clientId;
|
String? _clientId;
|
||||||
@ -51,4 +52,11 @@ class Auth with ChangeNotifier {
|
|||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var authProvider = ChangeNotifierProvider<Auth>((ref) => Auth());
|
||||||
|
@ -1,5 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/helpers/search-youtube.dart';
|
||||||
|
import 'package:spotube/provider/AudioPlayer.dart';
|
||||||
|
import 'package:spotube/provider/YouTube.dart';
|
||||||
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
class CurrentPlaylist {
|
class CurrentPlaylist {
|
||||||
List<Track>? _tempTrack;
|
List<Track>? _tempTrack;
|
||||||
@ -7,6 +16,7 @@ class CurrentPlaylist {
|
|||||||
String id;
|
String id;
|
||||||
String name;
|
String name;
|
||||||
String thumbnail;
|
String thumbnail;
|
||||||
|
|
||||||
CurrentPlaylist({
|
CurrentPlaylist({
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -36,13 +46,105 @@ class CurrentPlaylist {
|
|||||||
class Playback extends ChangeNotifier {
|
class Playback extends ChangeNotifier {
|
||||||
CurrentPlaylist? _currentPlaylist;
|
CurrentPlaylist? _currentPlaylist;
|
||||||
Track? _currentTrack;
|
Track? _currentTrack;
|
||||||
Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) {
|
|
||||||
_currentPlaylist = currentPlaylist;
|
// states
|
||||||
_currentTrack = currentTrack;
|
bool _isPlaying = false;
|
||||||
|
Duration? _duration;
|
||||||
|
|
||||||
|
// using custom listeners for duration as it changes super quickly
|
||||||
|
// which will cause re-renders in components that don't even need it
|
||||||
|
// thus only allowing to listen to change in duration through only
|
||||||
|
// a listener function
|
||||||
|
List<Function(Duration?)> _durationListeners = [];
|
||||||
|
|
||||||
|
// listeners
|
||||||
|
StreamSubscription<bool>? _playingStreamListener;
|
||||||
|
StreamSubscription<Duration?>? _durationStreamListener;
|
||||||
|
StreamSubscription<ProcessingState>? _processingStateStreamListener;
|
||||||
|
StreamSubscription<AudioInterruptionEvent>? _audioInterruptionEventListener;
|
||||||
|
|
||||||
|
AudioPlayer player;
|
||||||
|
YoutubeExplode youtube;
|
||||||
|
AudioSession? _audioSession;
|
||||||
|
Playback({
|
||||||
|
required this.player,
|
||||||
|
required this.youtube,
|
||||||
|
CurrentPlaylist? currentPlaylist,
|
||||||
|
Track? currentTrack,
|
||||||
|
}) : _currentPlaylist = currentPlaylist,
|
||||||
|
_currentTrack = currentTrack {
|
||||||
|
_playingStreamListener = player.playingStream.listen(
|
||||||
|
(playing) {
|
||||||
|
_isPlaying = playing;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_durationStreamListener = player.durationStream.listen((duration) async {
|
||||||
|
if (duration != null) {
|
||||||
|
// Actually things doesn't work all the time as they were
|
||||||
|
// described. So instead of listening to a `_ready`
|
||||||
|
// stream, it has to listen to duration stream since duration
|
||||||
|
// is always added to the Stream sink after all icyMetadata has
|
||||||
|
// been loaded thus indicating buffering started
|
||||||
|
if (duration != Duration.zero && duration != _duration) {
|
||||||
|
// this line is for prev/next or already playing playlist
|
||||||
|
if (player.playing) await player.pause();
|
||||||
|
await player.play();
|
||||||
|
}
|
||||||
|
_duration = duration;
|
||||||
|
_callAllDurationListeners(duration);
|
||||||
|
// for avoiding unnecessary re-renders in other components that
|
||||||
|
// doesn't need duration
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_processingStateStreamListener =
|
||||||
|
player.processingStateStream.listen((event) async {
|
||||||
|
try {
|
||||||
|
if (event == ProcessingState.completed && _currentTrack?.id != null) {
|
||||||
|
movePlaylistPositionBy(1);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
print("[PrecessingStateStreamListener] $e");
|
||||||
|
print(stack);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AudioSession.instance.then((session) async {
|
||||||
|
_audioSession = session;
|
||||||
|
await session.configure(const AudioSessionConfiguration.music());
|
||||||
|
_audioInterruptionEventListener = session.interruptionEventStream.listen(
|
||||||
|
(AudioInterruptionEvent event) {},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentPlaylist? get currentPlaylist => _currentPlaylist;
|
CurrentPlaylist? get currentPlaylist => _currentPlaylist;
|
||||||
Track? get currentTrack => _currentTrack;
|
Track? get currentTrack => _currentTrack;
|
||||||
|
bool get isPlaying => _isPlaying;
|
||||||
|
|
||||||
|
/// this duration field is almost static & changes occasionally
|
||||||
|
///
|
||||||
|
/// If you want realtime duration with state-update/re-render
|
||||||
|
/// use custom state & the [addDurationChangeListener] function to do so
|
||||||
|
Duration? get duration => _duration;
|
||||||
|
|
||||||
|
_callAllDurationListeners(Duration? arg) {
|
||||||
|
for (var listener in _durationListeners) {
|
||||||
|
listener(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDurationChangeListener(void Function(Duration? duration) listener) {
|
||||||
|
_durationListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeDurationChangeListener(
|
||||||
|
void Function(Duration? duration) listener) {
|
||||||
|
_durationListeners =
|
||||||
|
_durationListeners.where((p) => p != listener).toList();
|
||||||
|
}
|
||||||
|
|
||||||
set setCurrentTrack(Track track) {
|
set setCurrentTrack(Track track) {
|
||||||
_currentTrack = track;
|
_currentTrack = track;
|
||||||
@ -54,7 +156,10 @@ class Playback extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
void reset() {
|
||||||
|
_isPlaying = false;
|
||||||
|
_duration = null;
|
||||||
|
_callAllDurationListeners(null);
|
||||||
_currentPlaylist = null;
|
_currentPlaylist = null;
|
||||||
_currentTrack = null;
|
_currentTrack = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -75,6 +180,82 @@ class Playback extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dispose() {
|
||||||
|
_processingStateStreamListener?.cancel();
|
||||||
|
_durationStreamListener?.cancel();
|
||||||
|
_playingStreamListener?.cancel();
|
||||||
|
_audioInterruptionEventListener?.cancel();
|
||||||
|
_audioSession?.setActive(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
@ -1,10 +1,37 @@
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/Home/Home.dart';
|
||||||
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
|
import 'package:spotube/provider/Auth.dart';
|
||||||
|
|
||||||
class SpotifyDI with ChangeNotifier {
|
var spotifyProvider = Provider<SpotifyApi>((ref) {
|
||||||
SpotifyApi _spotifyApi;
|
Auth authState = ref.watch(authProvider);
|
||||||
|
|
||||||
SpotifyDI(this._spotifyApi);
|
return SpotifyApi(
|
||||||
|
SpotifyApiCredentials(
|
||||||
SpotifyApi get spotifyApi => _spotifyApi;
|
authState.clientId,
|
||||||
}
|
authState.clientSecret,
|
||||||
|
accessToken: authState.accessToken,
|
||||||
|
refreshToken: authState.refreshToken,
|
||||||
|
expiration: authState.expiration,
|
||||||
|
scopes: spotifyScopes,
|
||||||
|
),
|
||||||
|
onCredentialsRefreshed: (credentials) async {
|
||||||
|
SharedPreferences localStorage = await SharedPreferences.getInstance();
|
||||||
|
localStorage.setString(
|
||||||
|
LocalStorageKeys.refreshToken,
|
||||||
|
credentials.refreshToken!,
|
||||||
|
);
|
||||||
|
localStorage.setString(
|
||||||
|
LocalStorageKeys.accessToken,
|
||||||
|
credentials.accessToken!,
|
||||||
|
);
|
||||||
|
localStorage.setString(LocalStorageKeys.clientId, credentials.clientId!);
|
||||||
|
localStorage.setString(
|
||||||
|
LocalStorageKeys.clientSecret,
|
||||||
|
credentials.clientSecret!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
6
lib/provider/ThemeProvider.dart
Normal file
6
lib/provider/ThemeProvider.dart
Normal 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;
|
||||||
|
});
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
@ -110,3 +111,5 @@ class UserPreferences extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userPreferencesProvider = ChangeNotifierProvider((_) => UserPreferences());
|
||||||
|
4
lib/provider/YouTube.dart
Normal file
4
lib/provider/YouTube.dart
Normal 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());
|
147
pubspec.lock
147
pubspec.lock
@ -14,7 +14,7 @@ packages:
|
|||||||
name: archive
|
name: archive
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.8"
|
version: "3.2.1"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -30,7 +30,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.2"
|
version: "2.8.2"
|
||||||
audio_session:
|
audio_session:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: audio_session
|
name: audio_session
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
@ -121,7 +121,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
@ -180,7 +180,7 @@ packages:
|
|||||||
name: flutter_blurhash
|
name: flutter_blurhash
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.4"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -188,6 +188,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
|
flutter_hooks:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_hooks
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.18.2+1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -195,6 +202,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
flutter_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_riverpod
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -211,7 +225,21 @@ packages:
|
|||||||
name: freezed_annotation
|
name: freezed_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.3"
|
version: "1.1.0"
|
||||||
|
go_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: go_router
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.4"
|
||||||
|
hooks_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: hooks_riverpod
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
hotkey_manager:
|
hotkey_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -246,7 +274,7 @@ packages:
|
|||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.3"
|
||||||
infinite_scroll_pagination:
|
infinite_scroll_pagination:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -254,13 +282,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
injector:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: injector
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.0"
|
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -281,7 +302,7 @@ packages:
|
|||||||
name: just_audio
|
name: just_audio
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.18"
|
version: "0.9.20"
|
||||||
just_audio_libwinmedia:
|
just_audio_libwinmedia:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -295,14 +316,14 @@ packages:
|
|||||||
name: just_audio_platform_interface
|
name: just_audio_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.1.0"
|
||||||
just_audio_web:
|
just_audio_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: just_audio_web
|
name: just_audio_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.2"
|
version: "0.4.7"
|
||||||
libwinmedia:
|
libwinmedia:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -317,6 +338,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -344,14 +372,7 @@ packages:
|
|||||||
name: msix
|
name: msix
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.1"
|
version: "2.8.18"
|
||||||
nested:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: nested
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
oauth2:
|
oauth2:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -373,6 +394,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
palette_generator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: palette_generator
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.3"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -386,7 +414,7 @@ packages:
|
|||||||
name: path_provider
|
name: path_provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.0.9"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -407,28 +435,28 @@ packages:
|
|||||||
name: path_provider_linux
|
name: path_provider_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.5"
|
||||||
path_provider_macos:
|
path_provider_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_macos
|
name: path_provider_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.3"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
pedantic:
|
pedantic:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -456,7 +484,7 @@ packages:
|
|||||||
name: plugin_platform_interface
|
name: plugin_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.1.2"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -464,13 +492,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.4"
|
version: "4.2.4"
|
||||||
provider:
|
riverpod:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: provider
|
name: riverpod
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.1"
|
version: "1.0.3"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -484,35 +512,35 @@ packages:
|
|||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.11"
|
version: "2.0.13"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.9"
|
version: "2.0.11"
|
||||||
shared_preferences_ios:
|
shared_preferences_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_ios
|
name: shared_preferences_ios
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.1.0"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_linux
|
name: shared_preferences_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.1.0"
|
||||||
shared_preferences_macos:
|
shared_preferences_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_macos
|
name: shared_preferences_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.3"
|
||||||
shared_preferences_platform_interface:
|
shared_preferences_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -526,14 +554,14 @@ packages:
|
|||||||
name: shared_preferences_web
|
name: shared_preferences_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.3"
|
||||||
shared_preferences_windows:
|
shared_preferences_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_windows
|
name: shared_preferences_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.1.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -566,14 +594,14 @@ packages:
|
|||||||
name: sqflite
|
name: sqflite
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.2"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.2.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -581,6 +609,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.0"
|
version: "1.10.0"
|
||||||
|
state_notifier:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: state_notifier
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2+1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -629,63 +664,63 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.17"
|
version: "6.0.20"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.13"
|
version: "6.0.15"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.13"
|
version: "6.0.15"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "3.0.0"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "3.0.0"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
url_launcher_web:
|
url_launcher_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
version: "2.0.8"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "3.0.0"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.5"
|
version: "3.0.6"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -699,14 +734,14 @@ packages:
|
|||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.3"
|
version: "2.4.1"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xdg_directories
|
name: xdg_directories
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.0+1"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -727,7 +762,7 @@ packages:
|
|||||||
name: youtube_explode_dart
|
name: youtube_explode_dart
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.8"
|
version: "1.10.9+1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.15.1 <3.0.0"
|
dart: ">=2.15.1 <3.0.0"
|
||||||
flutter: ">=2.5.0"
|
flutter: ">=2.10.0"
|
||||||
|
@ -37,7 +37,6 @@ dependencies:
|
|||||||
cached_network_image: ^3.2.0
|
cached_network_image: ^3.2.0
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
http: ^0.13.4
|
http: ^0.13.4
|
||||||
provider: ^6.0.1
|
|
||||||
shared_preferences: ^2.0.11
|
shared_preferences: ^2.0.11
|
||||||
spotify: ^0.6.0
|
spotify: ^0.6.0
|
||||||
url_launcher: ^6.0.17
|
url_launcher: ^6.0.17
|
||||||
@ -49,6 +48,13 @@ dependencies:
|
|||||||
just_audio_libwinmedia: ^0.0.4
|
just_audio_libwinmedia: ^0.0.4
|
||||||
path: ^1.8.0
|
path: ^1.8.0
|
||||||
path_provider: ^2.0.8
|
path_provider: ^2.0.8
|
||||||
|
collection: ^1.15.0
|
||||||
|
flutter_riverpod: ^1.0.3
|
||||||
|
flutter_hooks: ^0.18.2+1
|
||||||
|
hooks_riverpod: ^1.0.3
|
||||||
|
go_router: ^3.0.4
|
||||||
|
palette_generator: ^0.3.3
|
||||||
|
audio_session: ^0.1.6+1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user