Compare commits
50 Commits
3f41573f00
...
1.0.0+12
| Author | SHA1 | Date | |
|---|---|---|---|
| 3df93e47d2 | |||
| 6d2a027d9b | |||
| 222d50d80d | |||
| 499bca5b1c | |||
| 252e4619f7 | |||
| 463cb9870f | |||
| 2cee7ee958 | |||
| e30e7a5c24 | |||
| 6509cd2511 | |||
| 6cdc025c40 | |||
| de3ad4b21e | |||
| ad1c188982 | |||
| 43fae51462 | |||
| 9012f560b5 | |||
| 19a7fd82df | |||
| 010ee6286f | |||
| 3c3447a9ee | |||
| ee2633db52 | |||
| ddeda2ce23 | |||
| a5f39321eb | |||
| da2a3508d1 | |||
| ed7b69f7b3 | |||
| 710ab755fc | |||
| 4fd9447591 | |||
| c97a7ae859 | |||
| 4bf8715486 | |||
| fbb12ff801 | |||
| 47d051dd44 | |||
| 1ac7704080 | |||
| b7b673c96d | |||
| f772bbdbbc | |||
| a95292a9ef | |||
| 07a86c32a0 | |||
| f16c216479 | |||
| 8b8915e28f | |||
| 0a24c86682 | |||
| d25ebbf6bd | |||
| be977f10d1 | |||
| bb09c43135 | |||
| 989440013c | |||
| d80a398a23 | |||
| 3ca01ef147 | |||
| 586f47575c | |||
| ef40c2ffe4 | |||
| 7e95c167ef | |||
| a063d19952 | |||
| 7285eb4959 | |||
| be44aadc07 | |||
| 249c8fbf80 | |||
| 2134500089 |
24
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: release-nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-exe:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter build windows
|
||||||
|
- name: Archive production artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-output-windows
|
||||||
|
path: build/windows/x64/runner/Release
|
||||||
@@ -8,10 +8,13 @@ Their original app is good enough. But I just want to redesign the user interfac
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] Playing music
|
- [x] Playing music
|
||||||
- [ ] Add netease music as source
|
- [x] Add netease music as source
|
||||||
|
- [ ] Add bilibili as source
|
||||||
|
- [ ] Add kuwo music as source
|
||||||
|
- [ ] Add kugo music as source
|
||||||
- [x] Re-design user interface
|
- [x] Re-design user interface
|
||||||
- [x] Simplified UI and UX
|
- [x] Simplified UI and UX
|
||||||
- [ ] Support for large screen device
|
- [x] Support for large screen device
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|||||||
BIN
android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
BIN
android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
19
android/app/src/main/res/values-night-v31/styles.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
19
android/app/src/main/res/values-v31/styles.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
BIN
assets/icon-w-shadow.png
Normal file
|
After Width: | Height: | Size: 553 KiB |
@@ -8,10 +8,24 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_broadcasts (0.0.1):
|
- flutter_broadcasts (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- flutter_inappwebview_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||||
|
- OrderedSet (~> 5.0)
|
||||||
|
- flutter_inappwebview_ios/Core (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- OrderedSet (~> 5.0)
|
||||||
|
- flutter_native_splash (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flutter_secure_storage (6.0.0):
|
||||||
|
- Flutter
|
||||||
- media_kit_libs_ios_audio (1.0.4):
|
- media_kit_libs_ios_audio (1.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- media_kit_native_event_loop (1.0.0):
|
- media_kit_native_event_loop (1.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- metadata_god (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- OrderedSet (5.0.0)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -23,6 +37,28 @@ PODS:
|
|||||||
- sqflite (0.0.3):
|
- sqflite (0.0.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- "sqlite3 (3.46.1+1)":
|
||||||
|
- "sqlite3/common (= 3.46.1+1)"
|
||||||
|
- "sqlite3/common (3.46.1+1)"
|
||||||
|
- "sqlite3/dbstatvtab (3.46.1+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- "sqlite3/fts5 (3.46.1+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- "sqlite3/perf-threadsafe (3.46.1+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- "sqlite3/rtree (3.46.1+1)":
|
||||||
|
- sqlite3/common
|
||||||
|
- sqlite3_flutter_libs (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- "sqlite3 (~> 3.46.0+1)"
|
||||||
|
- sqlite3/dbstatvtab
|
||||||
|
- sqlite3/fts5
|
||||||
|
- sqlite3/perf-threadsafe
|
||||||
|
- sqlite3/rtree
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- wakelock_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||||
@@ -30,12 +66,24 @@ DEPENDENCIES:
|
|||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
||||||
|
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||||
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`)
|
- media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`)
|
||||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
||||||
|
- metadata_god (from `.symlinks/plugins/metadata_god/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||||
|
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- OrderedSet
|
||||||
|
- sqlite3
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audio_service:
|
audio_service:
|
||||||
@@ -48,10 +96,18 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_broadcasts:
|
flutter_broadcasts:
|
||||||
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
||||||
|
flutter_inappwebview_ios:
|
||||||
|
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||||
|
flutter_native_splash:
|
||||||
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
|
flutter_secure_storage:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
media_kit_libs_ios_audio:
|
media_kit_libs_ios_audio:
|
||||||
:path: ".symlinks/plugins/media_kit_libs_ios_audio/ios"
|
:path: ".symlinks/plugins/media_kit_libs_ios_audio/ios"
|
||||||
media_kit_native_event_loop:
|
media_kit_native_event_loop:
|
||||||
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
||||||
|
metadata_god:
|
||||||
|
:path: ".symlinks/plugins/metadata_god/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -60,6 +116,12 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite:
|
sqflite:
|
||||||
:path: ".symlinks/plugins/sqflite/darwin"
|
:path: ".symlinks/plugins/sqflite/darwin"
|
||||||
|
sqlite3_flutter_libs:
|
||||||
|
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
wakelock_plus:
|
||||||
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||||
@@ -67,12 +129,21 @@ SPEC CHECKSUMS:
|
|||||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||||
|
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||||
|
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||||
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
||||||
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
||||||
|
metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9
|
||||||
|
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||||
|
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||||
|
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
|
||||||
|
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||||
|
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||||
|
|
||||||
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
|
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,6 @@
|
|||||||
1CF40EE9C145DC3FDC6C41BF /* Pods-RunnerTests.release.xcconfig */,
|
1CF40EE9C145DC3FDC6C41BF /* Pods-RunnerTests.release.xcconfig */,
|
||||||
DAFDCBCA918FE99EC399DF6B /* Pods-RunnerTests.profile.xcconfig */,
|
DAFDCBCA918FE99EC399DF6B /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -474,11 +473,13 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -495,7 +496,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -513,7 +514,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -529,7 +530,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -657,11 +658,13 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -680,11 +683,13 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
22
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "background.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "darkbackground.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage.png",
|
"filename" : "LaunchImage.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"version" : 1,
|
"author" : "xcode",
|
||||||
"author" : "xcode"
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 339 KiB |
@@ -16,13 +16,19 @@
|
|||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||||
</imageView>
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||||
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||||
|
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@@ -32,6 +38,7 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="LaunchImage" width="2050" height="2048"/>
|
||||||
|
<image name="LaunchBackground" width="1" height="1"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
<!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>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Rhythm Box</string>
|
<string>Groovy Box</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>rhythm_box</string>
|
<string>Groovy Box</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -22,12 +26,24 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.music</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>To provide information for RhythmBox to normalize the output audio</string>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<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>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
@@ -41,9 +57,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,37 +1,67 @@
|
|||||||
|
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player_stream.dart';
|
import 'package:rhythm_box/providers/audio_player_stream.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/endless_playback.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/providers/history.dart';
|
import 'package:rhythm_box/providers/history.dart';
|
||||||
import 'package:rhythm_box/providers/palette.dart';
|
import 'package:rhythm_box/providers/palette.dart';
|
||||||
|
import 'package:rhythm_box/providers/recent_played.dart';
|
||||||
import 'package:rhythm_box/providers/scrobbler.dart';
|
import 'package:rhythm_box/providers/scrobbler.dart';
|
||||||
import 'package:rhythm_box/providers/skip_segments.dart';
|
import 'package:rhythm_box/providers/skip_segments.dart';
|
||||||
import 'package:rhythm_box/providers/spotify.dart';
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/providers/volume.dart';
|
||||||
import 'package:rhythm_box/router.dart';
|
import 'package:rhythm_box/router.dart';
|
||||||
|
import 'package:rhythm_box/services/kv_store/encrypted_kv_store.dart';
|
||||||
|
import 'package:rhythm_box/services/kv_store/kv_store.dart';
|
||||||
import 'package:rhythm_box/services/lyrics/provider.dart';
|
import 'package:rhythm_box/services/lyrics/provider.dart';
|
||||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||||
import 'package:rhythm_box/services/server/routes/playback.dart';
|
import 'package:rhythm_box/services/server/routes/playback.dart';
|
||||||
import 'package:rhythm_box/services/server/server.dart';
|
import 'package:rhythm_box/services/server/server.dart';
|
||||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/services/wm_tools.dart';
|
||||||
|
import 'package:rhythm_box/shells/system_shell.dart';
|
||||||
import 'package:rhythm_box/translations.dart';
|
import 'package:rhythm_box/translations.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
import 'package:smtc_windows/smtc_windows.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> rawArgs) async {
|
||||||
|
if (rawArgs.contains('web_view_title_bar')) {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
if (runWebViewTitleBarWidget(rawArgs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
runApp(const MyApp());
|
if (PlatformInfo.isDesktop) {
|
||||||
|
await WindowManagerTools.initialize();
|
||||||
|
}
|
||||||
|
if (PlatformInfo.isWindows) {
|
||||||
|
await SMTCWindows.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
await KVStoreService.initialize();
|
||||||
|
await EncryptedKvStoreService.initialize();
|
||||||
|
|
||||||
|
runApp(const RhythmApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class RhythmApp extends StatelessWidget {
|
||||||
const MyApp({super.key});
|
const RhythmApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GetMaterialApp.router(
|
return GetMaterialApp.router(
|
||||||
title: 'DietaryGuard',
|
title: 'RhythmBox',
|
||||||
routerDelegate: router.routerDelegate,
|
routerDelegate: router.routerDelegate,
|
||||||
routeInformationParser: router.routeInformationParser,
|
routeInformationParser: router.routeInformationParser,
|
||||||
routeInformationProvider: router.routeInformationProvider,
|
routeInformationProvider: router.routeInformationProvider,
|
||||||
@@ -54,6 +84,13 @@ class MyApp extends StatelessWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
translations: AppTranslations(),
|
translations: AppTranslations(),
|
||||||
onInit: () => _initializeProviders(context),
|
onInit: () => _initializeProviders(context),
|
||||||
|
builder: (context, child) {
|
||||||
|
return ScaffoldMessenger(
|
||||||
|
child: SystemShell(
|
||||||
|
child: child ?? const SizedBox(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +98,10 @@ class MyApp extends StatelessWidget {
|
|||||||
Get.lazyPut(() => SpotifyProvider());
|
Get.lazyPut(() => SpotifyProvider());
|
||||||
Get.lazyPut(() => SyncedLyricsProvider());
|
Get.lazyPut(() => SyncedLyricsProvider());
|
||||||
|
|
||||||
|
Get.put(ErrorNotifier());
|
||||||
|
|
||||||
Get.put(DatabaseProvider());
|
Get.put(DatabaseProvider());
|
||||||
|
Get.put(AuthenticationProvider());
|
||||||
|
|
||||||
Get.put(AudioPlayerProvider());
|
Get.put(AudioPlayerProvider());
|
||||||
Get.put(ActiveSourcedTrackProvider());
|
Get.put(ActiveSourcedTrackProvider());
|
||||||
@@ -75,6 +115,9 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
Get.put(QueryingTrackInfoProvider());
|
Get.put(QueryingTrackInfoProvider());
|
||||||
Get.put(SourcedTrackProvider());
|
Get.put(SourcedTrackProvider());
|
||||||
|
Get.put(EndlessPlaybackProvider());
|
||||||
|
Get.put(VolumeProvider());
|
||||||
|
Get.put(RecentlyPlayedProvider());
|
||||||
|
|
||||||
Get.put(ServerPlaybackRoutesProvider());
|
Get.put(ServerPlaybackRoutesProvider());
|
||||||
Get.put(PlaybackServerProvider());
|
Get.put(PlaybackServerProvider());
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import 'package:media_kit/media_kit.dart' hide Track;
|
|||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/state.dart';
|
import 'package:rhythm_box/services/audio_player/state.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:rhythm_box/services/local_track.dart';
|
||||||
|
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
import 'package:spotify/spotify.dart' hide Playlist;
|
import 'package:spotify/spotify.dart' hide Playlist;
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
class AudioPlayerProvider extends GetxController {
|
class AudioPlayerProvider extends GetxController {
|
||||||
|
Rx<Duration> durationTotal = Rx(Duration.zero);
|
||||||
|
Rx<Duration> durationCurrent = Rx(Duration.zero);
|
||||||
|
Rx<Duration> durationBuffered = Rx(Duration.zero);
|
||||||
|
|
||||||
RxBool isPlaying = false.obs;
|
RxBool isPlaying = false.obs;
|
||||||
|
|
||||||
Rx<AudioPlayerState> state = Rx(AudioPlayerState(
|
Rx<AudioPlayerState> state = Rx(AudioPlayerState(
|
||||||
@@ -54,6 +61,11 @@ class AudioPlayerProvider extends GetxController {
|
|||||||
state.value = state.value.copyWith(playlist: playlist);
|
state.value = state.value.copyWith(playlist: playlist);
|
||||||
await _updatePlaylist(playlist);
|
await _updatePlaylist(playlist);
|
||||||
}),
|
}),
|
||||||
|
audioPlayer.durationStream.listen((value) => durationTotal.value = value),
|
||||||
|
audioPlayer.positionStream
|
||||||
|
.listen((value) => durationCurrent.value = value),
|
||||||
|
audioPlayer.bufferedPositionStream
|
||||||
|
.listen((value) => durationBuffered.value = value),
|
||||||
];
|
];
|
||||||
|
|
||||||
_readSavedState();
|
_readSavedState();
|
||||||
@@ -239,11 +251,12 @@ class AudioPlayerProvider extends GetxController {
|
|||||||
|
|
||||||
// Giving the initial track a boost so MediaKit won't skip
|
// Giving the initial track a boost so MediaKit won't skip
|
||||||
// because of timeout
|
// because of timeout
|
||||||
// final intendedActiveTrack = medias.elementAt(initialIndex);
|
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||||
// if (intendedActiveTrack.track is! LocalTrack) {
|
final intendedActiveTrack = medias.elementAt(initialIndex);
|
||||||
// await Get.find<SourcedTrackProvider>()
|
if (intendedActiveTrack.track is! LocalTrack) {
|
||||||
// .fetch(RhythmMedia(intendedActiveTrack.track));
|
await Get.find<SourcedTrackProvider>()
|
||||||
// }
|
.fetch(RhythmMedia(intendedActiveTrack.track));
|
||||||
|
}
|
||||||
|
|
||||||
if (medias.isEmpty) return;
|
if (medias.isEmpty) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/providers/history.dart';
|
import 'package:rhythm_box/providers/history.dart';
|
||||||
import 'package:rhythm_box/providers/palette.dart';
|
import 'package:rhythm_box/providers/palette.dart';
|
||||||
import 'package:rhythm_box/providers/scrobbler.dart';
|
import 'package:rhythm_box/providers/scrobbler.dart';
|
||||||
@@ -126,7 +127,8 @@ class AudioPlayerStreamProvider extends GetxController {
|
|||||||
.addTrack(playback.state.value.activeTrack!);
|
.addTrack(playback.state.value.activeTrack!);
|
||||||
lastScrobbled = uid;
|
lastScrobbled = uid;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log('[Scrobbler] Error: $e; Trace:\n$stack');
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
179
lib/providers/auth.dart
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/io.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:get/get.dart' hide Value;
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
|
||||||
|
extension ExpirationAuthenticationTableData on AuthenticationTableData {
|
||||||
|
bool get isExpired => DateTime.now().isAfter(spotifyExpiration);
|
||||||
|
|
||||||
|
String? getCookie(String key) => spotifyCookie.value
|
||||||
|
.split('; ')
|
||||||
|
.firstWhereOrNull((c) => c.trim().startsWith('$key='))
|
||||||
|
?.trim()
|
||||||
|
.split('=')
|
||||||
|
.last
|
||||||
|
.replaceAll(';', '');
|
||||||
|
|
||||||
|
String? getNeteaseCookie(String key) => neteaseCookie?.value
|
||||||
|
.split(';')
|
||||||
|
.firstWhereOrNull((c) => c.trim().startsWith('$key='))
|
||||||
|
?.trim()
|
||||||
|
.split('=')
|
||||||
|
.last
|
||||||
|
.replaceAll(';', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthenticationProvider extends GetxController {
|
||||||
|
static final Dio dio = () {
|
||||||
|
final dio = Dio();
|
||||||
|
|
||||||
|
(dio.httpClientAdapter as IOHttpClientAdapter)
|
||||||
|
.createHttpClient = () => HttpClient()
|
||||||
|
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||||
|
return host.endsWith('spotify.com') && port == 443;
|
||||||
|
};
|
||||||
|
|
||||||
|
return dio;
|
||||||
|
}();
|
||||||
|
|
||||||
|
var auth = Rxn<AuthenticationTableData?>();
|
||||||
|
Timer? refreshTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
loadAuthenticationData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadAuthenticationData() async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
final data = await (database.select(database.authenticationTable)
|
||||||
|
..where((s) => s.id.equals(0)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
auth.value = data;
|
||||||
|
_setRefreshTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setRefreshTimer() {
|
||||||
|
refreshTimer?.cancel();
|
||||||
|
if (auth.value != null && auth.value!.isExpired) {
|
||||||
|
refreshSpotifyCredentials();
|
||||||
|
}
|
||||||
|
refreshTimer = Timer(
|
||||||
|
auth.value!.spotifyExpiration.difference(DateTime.now()),
|
||||||
|
() => refreshSpotifyCredentials(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshSpotifyCredentials() async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
final refreshedCredentials =
|
||||||
|
await credentialsFromCookie(auth.value!.spotifyCookie.value);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.update(database.authenticationTable)
|
||||||
|
.replace(refreshedCredentials);
|
||||||
|
loadAuthenticationData(); // Reload data after refreshing
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> login(String cookie) async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
final refreshedCredentials = await credentialsFromCookie(cookie);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.into(database.authenticationTable)
|
||||||
|
.insert(refreshedCredentials, mode: InsertMode.replace);
|
||||||
|
loadAuthenticationData(); // Reload data after login
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthenticationTableCompanion> credentialsFromCookie(
|
||||||
|
String cookie) async {
|
||||||
|
try {
|
||||||
|
final spDc = cookie
|
||||||
|
.split('; ')
|
||||||
|
.firstWhereOrNull((c) => c.trim().startsWith('sp_dc='))
|
||||||
|
?.trim();
|
||||||
|
final res = await dio.getUri(
|
||||||
|
Uri.parse(
|
||||||
|
'https://open.spotify.com/get_access_token?reason=transport&productType=web_player'),
|
||||||
|
options: Options(
|
||||||
|
headers: {
|
||||||
|
'Cookie': spDc ?? '',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
validateStatus: (status) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final body = res.data;
|
||||||
|
|
||||||
|
if ((res.statusCode ?? 500) >= 400) {
|
||||||
|
throw Exception(
|
||||||
|
"Failed to get access token: ${body['error'] ?? res.statusMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthenticationTableCompanion.insert(
|
||||||
|
id: const Value(0),
|
||||||
|
spotifyCookie:
|
||||||
|
DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"),
|
||||||
|
spotifyAccessToken: DecryptedText(body['accessToken']),
|
||||||
|
spotifyExpiration: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
body['accessTokenExpirationTimestampMs']),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle error
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNeteaseCredentials(String cookie) async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
await database.update(database.authenticationTable).replace(
|
||||||
|
AuthenticationTableCompanion.insert(
|
||||||
|
id: const Value(0),
|
||||||
|
spotifyCookie: auth.value!.spotifyCookie,
|
||||||
|
spotifyAccessToken: auth.value!.spotifyAccessToken,
|
||||||
|
spotifyExpiration: auth.value!.spotifyExpiration,
|
||||||
|
neteaseCookie: Value(DecryptedText(cookie)),
|
||||||
|
neteaseExpiration: const Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await loadAuthenticationData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
auth.value = null;
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
await (database.delete(database.authenticationTable)
|
||||||
|
..where((s) => s.id.equals(0)))
|
||||||
|
.go();
|
||||||
|
// Additional cleanup if necessary
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logoutNetease() async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
await database.update(database.authenticationTable).replace(
|
||||||
|
AuthenticationTableCompanion.insert(
|
||||||
|
id: const Value(0),
|
||||||
|
spotifyCookie: auth.value!.spotifyCookie,
|
||||||
|
spotifyAccessToken: auth.value!.spotifyAccessToken,
|
||||||
|
spotifyExpiration: auth.value!.spotifyExpiration,
|
||||||
|
neteaseCookie: const Value(null),
|
||||||
|
neteaseExpiration: const Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await loadAuthenticationData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
refreshTimer?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
105
lib/providers/endless_playback.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
import 'error_notifier.dart';
|
||||||
|
|
||||||
|
class EndlessPlaybackProvider extends GetxController {
|
||||||
|
late final _auth = Get.find<AuthenticationProvider>();
|
||||||
|
late final _playback = Get.find<AudioPlayerProvider>();
|
||||||
|
late final _spotify = Get.find<SpotifyProvider>().api;
|
||||||
|
late final _preferences = Get.find<UserPreferencesProvider>();
|
||||||
|
|
||||||
|
bool get isEndlessPlayback => _preferences.state.value.endlessPlayback;
|
||||||
|
|
||||||
|
late final StreamSubscription _subscription;
|
||||||
|
|
||||||
|
StreamSubscription? _idxSubscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
_initPlayback();
|
||||||
|
|
||||||
|
_subscription = _preferences.state.listen((value) {
|
||||||
|
if (value.endlessPlayback && _idxSubscription == null) {
|
||||||
|
_initPlayback();
|
||||||
|
} else if (!value.endlessPlayback && _idxSubscription != null) {
|
||||||
|
_idxSubscription!.cancel();
|
||||||
|
_idxSubscription = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initPlayback() {
|
||||||
|
if (!isEndlessPlayback || _auth.auth.value == null) return;
|
||||||
|
|
||||||
|
void listener(int index) async {
|
||||||
|
try {
|
||||||
|
final playState = _playback.state.value;
|
||||||
|
if (index != playState.tracks.length - 1) return;
|
||||||
|
|
||||||
|
final track = playState.tracks.last;
|
||||||
|
|
||||||
|
final query = '${track.name} Radio';
|
||||||
|
final pages = await _spotify.search
|
||||||
|
.get(query, types: [SearchType.playlist]).first();
|
||||||
|
|
||||||
|
final radios = pages
|
||||||
|
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
|
||||||
|
.toList()
|
||||||
|
.cast<PlaylistSimple>();
|
||||||
|
|
||||||
|
final artists = track.artists!.map((e) => e.name);
|
||||||
|
|
||||||
|
final radio = radios.firstWhere(
|
||||||
|
(e) {
|
||||||
|
final validPlaylists =
|
||||||
|
artists.where((a) => e.description!.contains(a!));
|
||||||
|
return e.name == '${track.name} Radio' &&
|
||||||
|
(validPlaylists.length >= 2 ||
|
||||||
|
validPlaylists.length == artists.length) &&
|
||||||
|
e.owner?.displayName != 'Spotify';
|
||||||
|
},
|
||||||
|
orElse: () => radios.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
final tracks =
|
||||||
|
await _spotify.playlists.getTracksByPlaylistId(radio.id!).all();
|
||||||
|
|
||||||
|
await _playback.addTracks(
|
||||||
|
tracks.toList()
|
||||||
|
..removeWhere((e) {
|
||||||
|
final isDuplicate =
|
||||||
|
_playback.state.value.tracks.any((t) => t.id == e.id);
|
||||||
|
return e.id == track.id || isDuplicate;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[EndlessPlayback] Error: $e', trace: stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_playback.state.value.playlist.index ==
|
||||||
|
_playback.state.value.playlist.medias.length - 1 &&
|
||||||
|
_playback.isPlaying.value) {
|
||||||
|
listener(_playback.state.value.playlist.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
_idxSubscription = audioPlayer.currentIndexChangedStream.listen(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/providers/error_notifier.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class ErrorNotifier extends GetxController {
|
||||||
|
Rx<MaterialBanner?> showing = Rx(null);
|
||||||
|
|
||||||
|
void logError(String msg, {StackTrace? trace}) {
|
||||||
|
log('$msg${trace != null ? '\nTrace:\n$trace' : ''}');
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showError(String msg) {
|
||||||
|
showing.value = MaterialBanner(
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
leading: const Icon(Icons.error),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Something went wrong...',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(msg),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
showing.value = null;
|
||||||
|
},
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ class PaletteProvider extends GetxController {
|
|||||||
|
|
||||||
void updatePalette(PaletteGenerator? newPalette) {
|
void updatePalette(PaletteGenerator? newPalette) {
|
||||||
palette.value = newPalette;
|
palette.value = newPalette;
|
||||||
print('call update!');
|
|
||||||
print(newPalette);
|
|
||||||
if (newPalette != null) {
|
if (newPalette != null) {
|
||||||
Get.changeTheme(
|
Get.changeTheme(
|
||||||
ThemeData.from(
|
ThemeData.from(
|
||||||
|
|||||||
51
lib/providers/recent_played.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
|
||||||
|
class RecentlyPlayedProvider extends GetxController {
|
||||||
|
Future<List<HistoryTableData>> fetch() async {
|
||||||
|
final database = Get.find<DatabaseProvider>().database;
|
||||||
|
|
||||||
|
final uniqueItemIds = await (database.selectOnly(
|
||||||
|
database.historyTable,
|
||||||
|
distinct: true,
|
||||||
|
)
|
||||||
|
..addColumns([database.historyTable.itemId, database.historyTable.id])
|
||||||
|
..where(
|
||||||
|
database.historyTable.type.isInValues([
|
||||||
|
HistoryEntryType.playlist,
|
||||||
|
HistoryEntryType.album,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
..limit(10)
|
||||||
|
..orderBy([
|
||||||
|
OrderingTerm(
|
||||||
|
expression: database.historyTable.createdAt,
|
||||||
|
mode: OrderingMode.desc,
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.map(
|
||||||
|
(row) => row.read(database.historyTable.id),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
.then((value) => value.whereNotNull().toList());
|
||||||
|
|
||||||
|
final query = database.select(database.historyTable)
|
||||||
|
..where(
|
||||||
|
(tbl) => tbl.id.isIn(uniqueItemIds),
|
||||||
|
)
|
||||||
|
..orderBy([
|
||||||
|
(tbl) => OrderingTerm(
|
||||||
|
expression: tbl.createdAt,
|
||||||
|
mode: OrderingMode.desc,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final fetchedItems = await query.get();
|
||||||
|
return fetchedItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:get/get.dart' hide Value;
|
import 'package:get/get.dart' hide Value;
|
||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/artist.dart';
|
import 'package:rhythm_box/services/artist.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||||
@@ -44,7 +44,8 @@ class ScrobblerProvider extends GetxController {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log('[Scrobble] Error: $e; Trace:\n$stack');
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||||
scrobbler.value = null;
|
scrobbler.value = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -63,8 +64,9 @@ class ScrobblerProvider extends GetxController {
|
|||||||
timestamp: DateTime.now().toUtc(),
|
timestamp: DateTime.now().toUtc(),
|
||||||
trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stack) {
|
||||||
log('[Scrobble] Error: $e; Trace:\n$stackTrace');
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||||
@@ -72,7 +71,7 @@ Future<List<SkipSegmentTableData>> getAndCacheSkipSegments(String id) async {
|
|||||||
..where((s) => s.trackId.equals(id)))
|
..where((s) => s.trackId.equals(id)))
|
||||||
.get();
|
.get();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log('[SkipSegment] Error: $e; Trace:\n$stack');
|
Get.find<ErrorNotifier>().logError('[SkipSegment] Error: $e', trace: stack);
|
||||||
return List.castFrom<dynamic, SkipSegmentTableData>([]);
|
return List.castFrom<dynamic, SkipSegmentTableData>([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,59 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
class SpotifyProvider extends GetxController {
|
class SpotifyProvider extends GetxController {
|
||||||
late final SpotifyApi api;
|
late SpotifyApi api;
|
||||||
|
|
||||||
|
List<StreamSubscription>? _subscriptions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
api = SpotifyApi(
|
final AuthenticationProvider authenticate = Get.find();
|
||||||
|
if (authenticate.auth.value == null) {
|
||||||
|
api = _initApiWithClientCredentials();
|
||||||
|
} else {
|
||||||
|
api = _initApiWithUserCredentials();
|
||||||
|
}
|
||||||
|
_subscriptions = [
|
||||||
|
authenticate.auth.listen((value) {
|
||||||
|
if (value == null) {
|
||||||
|
api = _initApiWithClientCredentials();
|
||||||
|
} else {
|
||||||
|
api = _initApiWithUserCredentials();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
SpotifyApi _initApiWithClientCredentials() {
|
||||||
|
log('[SpotifyApi] Using client credentials...');
|
||||||
|
return SpotifyApi(
|
||||||
SpotifyApiCredentials(
|
SpotifyApiCredentials(
|
||||||
'f73d4bff91d64d89be9930036f553534',
|
'f73d4bff91d64d89be9930036f553534',
|
||||||
'5cbec0b928d247cd891d06195f07b8c9',
|
'5cbec0b928d247cd891d06195f07b8c9',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
super.onInit();
|
}
|
||||||
|
|
||||||
|
SpotifyApi _initApiWithUserCredentials() {
|
||||||
|
log('[SpotifyApi] Using user credentials...');
|
||||||
|
final AuthenticationProvider authenticate = Get.find();
|
||||||
|
return SpotifyApi.withAccessToken(
|
||||||
|
authenticate.auth.value!.spotifyAccessToken.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_subscriptions != null) {
|
||||||
|
for (final subscription in _subscriptions!) {
|
||||||
|
subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import 'package:rhythm_box/services/color.dart';
|
|||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
typedef UserPreferences = PreferencesTableData;
|
typedef UserPreferences = PreferencesTableData;
|
||||||
|
|
||||||
@@ -49,13 +49,7 @@ class UserPreferencesProvider extends GetxController {
|
|||||||
.listen((event) async {
|
.listen((event) async {
|
||||||
state.value = event;
|
state.value = event;
|
||||||
|
|
||||||
if (PlatformInfo.isDesktop) {
|
await WakelockPlus.toggle(enable: state.value.playerWakelock);
|
||||||
await windowManager.setTitleBarStyle(
|
|
||||||
state.value.systemTitleBar
|
|
||||||
? TitleBarStyle.normal
|
|
||||||
: TitleBarStyle.hidden,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await audioPlayer.setAudioNormalization(state.value.normalizeAudio);
|
await audioPlayer.setAudioNormalization(state.value.normalizeAudio);
|
||||||
});
|
});
|
||||||
@@ -147,6 +141,10 @@ class UserPreferencesProvider extends GetxController {
|
|||||||
setData(PreferencesTableCompanion(locale: Value(locale)));
|
setData(PreferencesTableCompanion(locale: Value(locale)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setNeteaseApiInstance(String instance) {
|
||||||
|
setData(PreferencesTableCompanion(neteaseApiInstance: Value(instance)));
|
||||||
|
}
|
||||||
|
|
||||||
void setPipedInstance(String instance) {
|
void setPipedInstance(String instance) {
|
||||||
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
|
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
|
||||||
}
|
}
|
||||||
@@ -167,14 +165,6 @@ class UserPreferencesProvider extends GetxController {
|
|||||||
setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar)));
|
setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDiscordPresence(bool discordPresence) {
|
|
||||||
setData(PreferencesTableCompanion(discordPresence: Value(discordPresence)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAmoledDarkTheme(bool isAmoled) {
|
|
||||||
setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void setNormalizeAudio(bool normalize) {
|
void setNormalizeAudio(bool normalize) {
|
||||||
setData(PreferencesTableCompanion(normalizeAudio: Value(normalize)));
|
setData(PreferencesTableCompanion(normalizeAudio: Value(normalize)));
|
||||||
audioPlayer.setAudioNormalization(normalize);
|
audioPlayer.setAudioNormalization(normalize);
|
||||||
@@ -184,7 +174,8 @@ class UserPreferencesProvider extends GetxController {
|
|||||||
setData(PreferencesTableCompanion(endlessPlayback: Value(endless)));
|
setData(PreferencesTableCompanion(endlessPlayback: Value(endless)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setEnableConnect(bool enable) {
|
void setPlayerWakelock(bool wakelock) {
|
||||||
setData(PreferencesTableCompanion(enableConnect: Value(enable)));
|
setData(PreferencesTableCompanion(playerWakelock: Value(wakelock)));
|
||||||
|
WakelockPlus.toggle(enable: wakelock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
lib/providers/volume.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/services/kv_store/kv_store.dart';
|
||||||
|
|
||||||
|
class VolumeProvider extends GetxController {
|
||||||
|
RxDouble volume = KVStoreService.volume.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
audioPlayer.setVolume(volume.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double newVolume) async {
|
||||||
|
volume.value = newVolume;
|
||||||
|
await audioPlayer.setVolume(newVolume);
|
||||||
|
KVStoreService.setVolume(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import 'package:animations/animations.dart';
|
import 'package:animations/animations.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/screens/about.dart';
|
||||||
|
import 'package:rhythm_box/screens/album/view.dart';
|
||||||
|
import 'package:rhythm_box/screens/auth/login_netease.dart';
|
||||||
|
import 'package:rhythm_box/screens/auth/mobile_login.dart';
|
||||||
import 'package:rhythm_box/screens/explore.dart';
|
import 'package:rhythm_box/screens/explore.dart';
|
||||||
|
import 'package:rhythm_box/screens/library/view.dart';
|
||||||
import 'package:rhythm_box/screens/player/lyrics.dart';
|
import 'package:rhythm_box/screens/player/lyrics.dart';
|
||||||
|
import 'package:rhythm_box/screens/player/mini.dart';
|
||||||
import 'package:rhythm_box/screens/player/view.dart';
|
import 'package:rhythm_box/screens/player/view.dart';
|
||||||
import 'package:rhythm_box/screens/playlist/view.dart';
|
import 'package:rhythm_box/screens/playlist/view.dart';
|
||||||
import 'package:rhythm_box/screens/search/view.dart';
|
import 'package:rhythm_box/screens/search/view.dart';
|
||||||
@@ -18,6 +24,11 @@ final router = GoRouter(routes: [
|
|||||||
name: 'explore',
|
name: 'explore',
|
||||||
builder: (context, state) => const ExploreScreen(),
|
builder: (context, state) => const ExploreScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/library',
|
||||||
|
name: 'library',
|
||||||
|
builder: (context, state) => const LibraryScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/search',
|
path: '/search',
|
||||||
name: 'search',
|
name: 'search',
|
||||||
@@ -30,11 +41,23 @@ final router = GoRouter(routes: [
|
|||||||
playlistId: state.pathParameters['id']!,
|
playlistId: state.pathParameters['id']!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/albums/:id',
|
||||||
|
name: 'albumView',
|
||||||
|
builder: (context, state) => AlbumViewScreen(
|
||||||
|
albumId: state.pathParameters['id']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
builder: (context, state) => const SettingsScreen(),
|
builder: (context, state) => const SettingsScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/about',
|
||||||
|
name: 'about',
|
||||||
|
builder: (context, state) => const AboutScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@@ -62,4 +85,31 @@ final router = GoRouter(routes: [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => child,
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/player/mini',
|
||||||
|
name: 'playerMini',
|
||||||
|
builder: (context, state) => MiniPlayerScreen(
|
||||||
|
prevSize: state.extra as Size,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => child,
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/mobile-login',
|
||||||
|
name: 'authMobileLogin',
|
||||||
|
builder: (context, state) => const MobileLogin(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/netease/login',
|
||||||
|
name: 'authMobileLoginNetease',
|
||||||
|
builder: (context, state) => const LoginNeteaseScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
93
lib/screens/about.dart
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
class AboutScreen extends StatelessWidget {
|
||||||
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const denseButtonStyle =
|
||||||
|
ButtonStyle(visualDensity: VisualDensity(vertical: -4));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('About'),
|
||||||
|
),
|
||||||
|
body: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
|
child: Image.asset('assets/icon.png', width: 120, height: 120),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
PlatformInfo.isIOS || PlatformInfo.isMacOS
|
||||||
|
? 'GroovyBox'
|
||||||
|
: 'RhythmBox',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Yet another Spotify third-party app',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FutureBuilder(
|
||||||
|
future: PackageInfo.fromPlatform(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}',
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
style: denseButtonStyle,
|
||||||
|
child: const Text('App Details'),
|
||||||
|
onPressed: () async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
|
||||||
|
showAboutDialog(
|
||||||
|
context: context,
|
||||||
|
applicationVersion: '${info.version} (${info.buildNumber})',
|
||||||
|
applicationLegalese: 'Yet another Spotify third-party app.',
|
||||||
|
applicationIcon: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
|
child:
|
||||||
|
Image.asset('assets/icon.png', width: 60, height: 60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
style: denseButtonStyle,
|
||||||
|
child: const Text('Project Website'),
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString('https://solsynth.dev/products/rhythm-box');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Open-sourced under AGPLv3',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
lib/screens/album/view.dart
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/history.dart';
|
||||||
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
|
import 'package:rhythm_box/services/artist.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/services/track.dart';
|
||||||
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
class AlbumViewScreen extends StatefulWidget {
|
||||||
|
final String albumId;
|
||||||
|
final Playlist? playlist;
|
||||||
|
|
||||||
|
const AlbumViewScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
this.playlist,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlbumViewScreen> createState() => _AlbumViewScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumViewScreenState extends State<AlbumViewScreen> {
|
||||||
|
late final SpotifyProvider _spotify = Get.find();
|
||||||
|
late final AudioPlayerProvider _playback = Get.find();
|
||||||
|
|
||||||
|
bool get _isCurrentPlaylist => _album != null
|
||||||
|
? _playback.state.value.containsCollection(_album!.id!)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isLoadingTracks = true;
|
||||||
|
bool _isUpdating = false;
|
||||||
|
|
||||||
|
Album? _album;
|
||||||
|
List<Track>? _tracks;
|
||||||
|
|
||||||
|
Future<void> _pullPlaylist() async {
|
||||||
|
_album = await _spotify.api.albums.get(widget.albumId);
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pullTracks() async {
|
||||||
|
_tracks = (await _spotify.api.albums.tracks(widget.albumId).all())
|
||||||
|
.map((x) => x.asTrack(_album!))
|
||||||
|
.toList();
|
||||||
|
setState(() => _isLoadingTracks = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pullPlaylist();
|
||||||
|
_pullTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const radius = BorderRadius.all(Radius.circular(8));
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Playlist'),
|
||||||
|
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||||
|
),
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CenteredContainer(
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
borderRadius: radius,
|
||||||
|
elevation: 2,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: (_album?.images?.isNotEmpty ?? false)
|
||||||
|
? AutoCacheImage(
|
||||||
|
_album!.images!.first.url!,
|
||||||
|
width: 160.0,
|
||||||
|
height: 160.0,
|
||||||
|
)
|
||||||
|
: const SizedBox(
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
child: Icon(Icons.image),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_album!.name ?? 'Playlist',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_album!.artists?.asString() ?? 'A Playlist',
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_album!.popularity ?? 0)} views",
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'#${_album!.id}',
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingOnly(left: 24, right: 24, top: 24),
|
||||||
|
const Gap(8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Obx(
|
||||||
|
() => ElevatedButton.icon(
|
||||||
|
icon: (_isCurrentPlaylist &&
|
||||||
|
_playback.isPlaying.value)
|
||||||
|
? const Icon(Icons.pause_outlined)
|
||||||
|
: const Icon(Icons.play_arrow),
|
||||||
|
label: const Text('Play'),
|
||||||
|
onPressed: _isUpdating
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
if (_isCurrentPlaylist &&
|
||||||
|
_playback.isPlaying.value) {
|
||||||
|
audioPlayer.pause();
|
||||||
|
return;
|
||||||
|
} else if (_isCurrentPlaylist &&
|
||||||
|
!_playback.isPlaying.value) {
|
||||||
|
audioPlayer.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isUpdating = true);
|
||||||
|
|
||||||
|
await _playback.load(_tracks!,
|
||||||
|
autoPlay: true);
|
||||||
|
_playback.addCollection(_album!.id!);
|
||||||
|
Get.find<PlaybackHistoryProvider>()
|
||||||
|
.addAlbums([_album!]);
|
||||||
|
|
||||||
|
setState(() => _isUpdating = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.shuffle),
|
||||||
|
label: const Text('Shuffle'),
|
||||||
|
onPressed: _isUpdating
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() => _isUpdating = true);
|
||||||
|
|
||||||
|
audioPlayer.setShuffle(true);
|
||||||
|
|
||||||
|
await _playback.load(
|
||||||
|
_tracks!,
|
||||||
|
autoPlay: true,
|
||||||
|
initialIndex:
|
||||||
|
Random().nextInt(_tracks!.length),
|
||||||
|
);
|
||||||
|
_playback.addCollection(_album!.id!);
|
||||||
|
Get.find<PlaybackHistoryProvider>()
|
||||||
|
.addAlbums([_album!]);
|
||||||
|
|
||||||
|
setState(() => _isUpdating = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 24),
|
||||||
|
const Gap(24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
'Songs (${_tracks?.length ?? 0})',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||||
|
),
|
||||||
|
PlaylistTrackList(
|
||||||
|
isLoading: _isLoadingTracks,
|
||||||
|
playlistId: widget.albumId,
|
||||||
|
tracks: _tracks,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
lib/screens/auth/desktop_login.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
|
||||||
|
Future<void> desktopLogin(BuildContext context) async {
|
||||||
|
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
|
||||||
|
final applicationSupportDir = await getApplicationSupportDirectory();
|
||||||
|
final userDataFolder =
|
||||||
|
Directory(join(applicationSupportDir.path, 'webview_window_Webview2'));
|
||||||
|
|
||||||
|
if (!await userDataFolder.exists()) {
|
||||||
|
await userDataFolder.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
final webview = await WebviewWindow.create(
|
||||||
|
configuration: CreateConfiguration(
|
||||||
|
title: 'Spotify Login',
|
||||||
|
titleBarTopPadding: PlatformInfo.isMacOS ? 20 : 0,
|
||||||
|
windowHeight: 720,
|
||||||
|
windowWidth: 1280,
|
||||||
|
userDataFolderWindows: userDataFolder.path,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
webview
|
||||||
|
..setBrightness(Theme.of(context).colorScheme.brightness)
|
||||||
|
..launch('https://accounts.spotify.com/')
|
||||||
|
..setOnUrlRequestCallback((url) {
|
||||||
|
if (exp.hasMatch(url)) {
|
||||||
|
webview.getAllCookies().then((cookies) async {
|
||||||
|
final cookieHeader =
|
||||||
|
"sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}";
|
||||||
|
|
||||||
|
final AuthenticationProvider authenticate = Get.find();
|
||||||
|
await authenticate.login(cookieHeader);
|
||||||
|
|
||||||
|
webview.close();
|
||||||
|
if (context.mounted) {
|
||||||
|
GoRouter.of(context).go('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
13
lib/screens/auth/login.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/screens/auth/desktop_login.dart';
|
||||||
|
|
||||||
|
Future<void> universalLogin(BuildContext context) async {
|
||||||
|
if (PlatformInfo.isMobile) {
|
||||||
|
GoRouter.of(context).pushNamed('authMobileLogin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await desktopLogin(context);
|
||||||
|
}
|
||||||
156
lib/screens/auth/login_netease.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
|
|
||||||
|
class LoginNeteaseScreen extends StatefulWidget {
|
||||||
|
const LoginNeteaseScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginNeteaseScreen> createState() => _LoginNeteaseScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginNeteaseScreenState extends State<LoginNeteaseScreen> {
|
||||||
|
final TextEditingController _phoneController = TextEditingController();
|
||||||
|
final TextEditingController _phoneRegionController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
late final AuthenticationProvider _auth = Get.find();
|
||||||
|
|
||||||
|
bool _isLogging = false;
|
||||||
|
|
||||||
|
Future<void> _sentCaptcha() async {
|
||||||
|
setState(() => _isLogging = true);
|
||||||
|
|
||||||
|
final phone = _phoneController.text;
|
||||||
|
var region = _phoneRegionController.text;
|
||||||
|
if (region.isEmpty) region = '86';
|
||||||
|
final client = NeteaseSourcedTrack.getClient();
|
||||||
|
final resp = await client.get(
|
||||||
|
'/captcha/sent?phone=$phone&ctcode=$region×tamp=${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.statusCode != 200 || resp.body?['code'] != 200) {
|
||||||
|
Get.find<ErrorNotifier>().showError(
|
||||||
|
resp.bodyString ?? resp.status.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isLogging = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performLogin() async {
|
||||||
|
setState(() => _isLogging = true);
|
||||||
|
|
||||||
|
final phone = _phoneController.text;
|
||||||
|
final password = _passwordController.text;
|
||||||
|
var region = _phoneRegionController.text;
|
||||||
|
if (region.isEmpty) region = '86';
|
||||||
|
final client = NeteaseSourcedTrack.getClient();
|
||||||
|
final resp = await client.get(
|
||||||
|
'/login/cellphone?phone=$phone&captcha=$password&countrycode=$region×tamp=${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.statusCode != 200 || resp.body?['code'] != 200) {
|
||||||
|
Get.find<ErrorNotifier>().showError(
|
||||||
|
resp.bodyString ?? resp.status.toString(),
|
||||||
|
);
|
||||||
|
setState(() => _isLogging = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _auth.setNeteaseCredentials(resp.body['cookie']);
|
||||||
|
|
||||||
|
setState(() => _isLogging = false);
|
||||||
|
|
||||||
|
GoRouter.of(context).goNamed('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneController.dispose();
|
||||||
|
_phoneRegionController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Connect with Netease Cloud Music'),
|
||||||
|
),
|
||||||
|
body: CenteredContainer(
|
||||||
|
maxWidth: 320,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: TextField(
|
||||||
|
controller: _phoneRegionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: '86',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
label: Text('Phone Number'),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
label: Text('Captcha Code'),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLogging ? null : () => _sentCaptcha(),
|
||||||
|
icon: const Icon(Icons.sms),
|
||||||
|
tooltip: 'Get Captcha',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isLogging ? null : () => _performLogin(),
|
||||||
|
child: const Text('Login'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/screens/auth/mobile_login.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
|
||||||
|
class MobileLogin extends StatelessWidget {
|
||||||
|
const MobileLogin({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final AuthenticationProvider authenticate = Get.find();
|
||||||
|
|
||||||
|
if (PlatformInfo.isDesktop) {
|
||||||
|
const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text('This feature is not available on desktop'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Connect with Spotify'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: InAppWebView(
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
|
||||||
|
),
|
||||||
|
initialUrlRequest: URLRequest(
|
||||||
|
url: WebUri('https://accounts.spotify.com/'),
|
||||||
|
),
|
||||||
|
onPermissionRequest: (controller, permissionRequest) async {
|
||||||
|
return PermissionResponse(
|
||||||
|
resources: permissionRequest.resources,
|
||||||
|
action: PermissionResponseAction.GRANT,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLoadStop: (controller, action) async {
|
||||||
|
if (action == null) return;
|
||||||
|
String url = action.toString();
|
||||||
|
if (url.endsWith('/')) {
|
||||||
|
url = url.substring(0, url.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
|
||||||
|
|
||||||
|
if (exp.hasMatch(url)) {
|
||||||
|
final cookies =
|
||||||
|
await CookieManager.instance().getCookies(url: action);
|
||||||
|
final cookieHeader =
|
||||||
|
"sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}";
|
||||||
|
|
||||||
|
await authenticate.login(cookieHeader);
|
||||||
|
if (context.mounted) {
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/providers/recent_played.dart';
|
||||||
import 'package:rhythm_box/providers/spotify.dart';
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:rhythm_box/services/album.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:rhythm_box/services/spotify/spotify_endpoints.dart';
|
||||||
|
import 'package:rhythm_box/widgets/playlist/playlist_section.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
class ExploreScreen extends StatefulWidget {
|
class ExploreScreen extends StatefulWidget {
|
||||||
@@ -15,15 +21,71 @@ class ExploreScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ExploreScreenState extends State<ExploreScreen> {
|
class _ExploreScreenState extends State<ExploreScreen> {
|
||||||
late final SpotifyProvider _spotify = Get.find();
|
late final SpotifyProvider _spotify = Get.find();
|
||||||
|
late final RecentlyPlayedProvider _history = Get.find();
|
||||||
|
late final AuthenticationProvider _auth = Get.find();
|
||||||
|
|
||||||
bool _isLoading = true;
|
final Map<String, bool> _isLoading = {
|
||||||
|
'featured': true,
|
||||||
|
'recently': true,
|
||||||
|
'newReleases': true,
|
||||||
|
'forYou': true,
|
||||||
|
};
|
||||||
|
|
||||||
List<PlaylistSimple>? _featuredPlaylist;
|
List<Object>? _featuredPlaylist;
|
||||||
|
List<Object>? _recentlyPlaylist;
|
||||||
|
List<Object>? _newReleasesPlaylist;
|
||||||
|
List<dynamic>? _forYouView;
|
||||||
|
|
||||||
Future<void> _pullPlaylist() async {
|
Future<void> _pullPlaylist() async {
|
||||||
|
final market = Get.find<UserPreferencesProvider>().state.value.market;
|
||||||
|
final locale = Get.find<UserPreferencesProvider>().state.value.locale;
|
||||||
|
|
||||||
_featuredPlaylist =
|
_featuredPlaylist =
|
||||||
(await _spotify.api.playlists.featured.getPage(20)).items!.toList();
|
(await _spotify.api.playlists.featured.getPage(20)).items!.toList();
|
||||||
setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading['featured'] = false);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final idxList = Set();
|
||||||
|
_recentlyPlaylist = (await _history.fetch())
|
||||||
|
.where((x) => x.playlist != null)
|
||||||
|
.map((x) => x.playlist!)
|
||||||
|
.toList()
|
||||||
|
..retainWhere((x) => idxList.add(x.id!));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading['recently'] = false);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_newReleasesPlaylist =
|
||||||
|
(await _spotify.api.browse.newReleases(country: market).getPage(20))
|
||||||
|
.items
|
||||||
|
?.map((album) => album.toAlbum())
|
||||||
|
.toList();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading['newReleases'] = false);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_auth.auth.value != null) {
|
||||||
|
final customEndpoint = CustomSpotifyEndpoints(
|
||||||
|
_auth.auth.value?.spotifyAccessToken.value ?? '');
|
||||||
|
final forYouView = await customEndpoint.getView(
|
||||||
|
'made-for-x-hub',
|
||||||
|
market: market,
|
||||||
|
locale: Intl.canonicalizedLocale(locale.toString()),
|
||||||
|
);
|
||||||
|
_forYouView = forYouView['content']?['items'];
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading['forYou'] = false);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,46 +101,55 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('explore'.tr),
|
title: Text('explore'.tr),
|
||||||
|
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||||
),
|
),
|
||||||
body: Skeletonizer(
|
body: CustomScrollView(
|
||||||
enabled: _isLoading,
|
slivers: [
|
||||||
child: ListView.builder(
|
if (_recentlyPlaylist?.isNotEmpty ?? false)
|
||||||
itemCount: _featuredPlaylist?.length ?? 20,
|
SliverToBoxAdapter(
|
||||||
itemBuilder: (context, idx) {
|
child: PlaylistSection(
|
||||||
final item = _featuredPlaylist?[idx];
|
isLoading: _isLoading['recently']!,
|
||||||
return ListTile(
|
title: 'Recent Played',
|
||||||
leading: ClipRRect(
|
list: _recentlyPlaylist,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
child: item != null
|
|
||||||
? AutoCacheImage(
|
|
||||||
item.images!.first.url!,
|
|
||||||
width: 64.0,
|
|
||||||
height: 64.0,
|
|
||||||
)
|
|
||||||
: const SizedBox(
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.image),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(item?.name ?? 'Loading...'),
|
),
|
||||||
subtitle: Text(
|
if (_recentlyPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||||
item?.description ?? 'Please stand by...',
|
if (_newReleasesPlaylist?.isNotEmpty ?? false)
|
||||||
maxLines: 2,
|
SliverToBoxAdapter(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: PlaylistSection(
|
||||||
|
isLoading: _isLoading['newReleases']!,
|
||||||
|
title: 'New Releases',
|
||||||
|
list: _newReleasesPlaylist,
|
||||||
),
|
),
|
||||||
onTap: () {
|
),
|
||||||
if (item == null) return;
|
if (_newReleasesPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||||
GoRouter.of(context).pushNamed(
|
SliverList.builder(
|
||||||
'playlistView',
|
itemCount: _forYouView?.length ?? 0,
|
||||||
pathParameters: {'id': item.id!},
|
itemBuilder: (context, idx) {
|
||||||
);
|
final item = _forYouView![idx];
|
||||||
},
|
final playlists = item['content']?['items']
|
||||||
);
|
?.where((itemL2) => itemL2['type'] == 'playlist')
|
||||||
},
|
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
|
||||||
),
|
.toList()
|
||||||
|
.cast<PlaylistSimple>() ??
|
||||||
|
<PlaylistSimple>[];
|
||||||
|
if (playlists.isEmpty) return const SizedBox.shrink();
|
||||||
|
return PlaylistSection(
|
||||||
|
isLoading: false,
|
||||||
|
title: item['name'] ?? '',
|
||||||
|
list: playlists,
|
||||||
|
).paddingOnly(bottom: 16);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: PlaylistSection(
|
||||||
|
isLoading: _isLoading['featured']!,
|
||||||
|
title: 'Featured',
|
||||||
|
list: _featuredPlaylist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
43
lib/screens/library/view.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/widgets/no_login_fallback.dart';
|
||||||
|
import 'package:rhythm_box/widgets/playlist/user_playlist_list.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
|
|
||||||
|
class LibraryScreen extends StatefulWidget {
|
||||||
|
const LibraryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LibraryScreen> createState() => _LibraryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LibraryScreenState extends State<LibraryScreen> {
|
||||||
|
late final AuthenticationProvider _authenticate = Get.find();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Library'),
|
||||||
|
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||||
|
),
|
||||||
|
body: Obx(() {
|
||||||
|
if (_authenticate.auth.value == null) {
|
||||||
|
return const NoLoginFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const CenteredContainer(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: UserPlaylistList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:rhythm_box/widgets/lyrics/synced.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
||||||
import 'package:rhythm_box/widgets/player/bottom_player.dart';
|
import 'package:rhythm_box/widgets/player/bottom_player.dart';
|
||||||
|
|
||||||
class LyricsScreen extends StatelessWidget {
|
class LyricsScreen extends StatefulWidget {
|
||||||
const LyricsScreen({super.key});
|
const LyricsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LyricsScreen> createState() => _LyricsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LyricsScreenState extends State<LyricsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
@@ -22,13 +30,15 @@ class LyricsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: const SizedBox(
|
bottomNavigationBar: SizedBox(
|
||||||
height: 85,
|
height: 85 + max(MediaQuery.of(context).padding.bottom, 16),
|
||||||
child: Material(
|
child: Material(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
child: BottomPlayer(
|
child: const BottomPlayer(
|
||||||
key: Key('lyrics-page-bottom-player'),
|
key: Key('lyrics-page-bottom-player'),
|
||||||
usePop: true,
|
usePop: true,
|
||||||
|
).paddingOnly(
|
||||||
|
bottom: max(MediaQuery.of(context).padding.bottom, 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
205
lib/screens/player/mini.dart
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/screens/player/queue.dart';
|
||||||
|
import 'package:rhythm_box/screens/player/siblings.dart';
|
||||||
|
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/bottom_player.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/devices.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
class MiniPlayerScreen extends StatefulWidget {
|
||||||
|
final Size prevSize;
|
||||||
|
|
||||||
|
const MiniPlayerScreen({super.key, required this.prevSize});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MiniPlayerScreen> createState() => _MiniPlayerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MiniPlayerScreenState extends State<MiniPlayerScreen> {
|
||||||
|
bool _wasMaximized = false;
|
||||||
|
|
||||||
|
bool _areaActive = false;
|
||||||
|
bool _isHoverMode = true;
|
||||||
|
|
||||||
|
void _exitMiniPlayer() async {
|
||||||
|
if (!PlatformInfo.isDesktop) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await windowManager.setMinimumSize(const Size(300, 700));
|
||||||
|
await windowManager.setAlwaysOnTop(false);
|
||||||
|
if (_wasMaximized) {
|
||||||
|
await windowManager.maximize();
|
||||||
|
} else {
|
||||||
|
await windowManager.setSize(widget.prevSize);
|
||||||
|
}
|
||||||
|
await windowManager.setAlignment(Alignment.center);
|
||||||
|
if (!PlatformInfo.isLinux) {
|
||||||
|
await windowManager.setHasShadow(true);
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
if (GoRouter.of(context).canPop()) {
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
} else {
|
||||||
|
GoRouter.of(context).replaceNamed('player');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (PlatformInfo.isDesktop) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
_wasMaximized = await windowManager.isMaximized();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: !_isHoverMode
|
||||||
|
? null
|
||||||
|
: (event) {
|
||||||
|
setState(() => _areaActive = true);
|
||||||
|
},
|
||||||
|
onExit: !_isHoverMode
|
||||||
|
? null
|
||||||
|
: (event) {
|
||||||
|
setState(() => _areaActive = false);
|
||||||
|
},
|
||||||
|
child: DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(60),
|
||||||
|
child: AnimatedCrossFade(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
crossFadeState: _areaActive
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
|
secondChild: const SizedBox(),
|
||||||
|
firstChild: Material(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.fullscreen_exit),
|
||||||
|
onPressed: () => _exitMiniPlayer(),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.speaker, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const PlayerDevicePopup(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.merge),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const SiblingTracksPopup(),
|
||||||
|
).then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.queue_music),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const PlayerQueuePopup(),
|
||||||
|
).then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: _isHoverMode
|
||||||
|
? const Icon(Icons.touch_app)
|
||||||
|
: const Icon(Icons.touch_app_outlined),
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor: _isHoverMode
|
||||||
|
? WidgetStateProperty.all(theme.colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
_areaActive = true;
|
||||||
|
_isHoverMode = !_isHoverMode;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (PlatformInfo.isDesktop)
|
||||||
|
FutureBuilder(
|
||||||
|
future: windowManager.isAlwaysOnTop(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
snapshot.data == true
|
||||||
|
? Icons.push_pin
|
||||||
|
: Icons.push_pin_outlined,
|
||||||
|
),
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor: snapshot.data == true
|
||||||
|
? WidgetStateProperty.all(
|
||||||
|
theme.colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onPressed: snapshot.data == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await windowManager.setAlwaysOnTop(
|
||||||
|
snapshot.data == true ? false : true,
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: SyncedLyrics(defaultTextZoom: 67)),
|
||||||
|
SizedBox(
|
||||||
|
height: 85,
|
||||||
|
child: BottomPlayer(
|
||||||
|
isMiniPlayer: true,
|
||||||
|
usePop: true,
|
||||||
|
onTap: () => _exitMiniPlayer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lib/screens/player/siblings.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/sibling_tracks.dart';
|
||||||
|
|
||||||
|
class SiblingTracksPopup extends StatelessWidget {
|
||||||
|
const SiblingTracksPopup({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.85,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Alternative Sources',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: SiblingTracks(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/screens/player/source_details.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/track_source_details.dart';
|
||||||
|
|
||||||
|
class SourceDetailsPopup extends StatelessWidget {
|
||||||
|
const SourceDetailsPopup({super.key});
|
||||||
|
|
||||||
|
Future<SourcedTrack?> _pullActiveTrack() async {
|
||||||
|
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
|
||||||
|
return activeSourcedTrack.state.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Source Details',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(
|
||||||
|
() => FutureBuilder(
|
||||||
|
future: _pullActiveTrack(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return TrackSourceDetails(
|
||||||
|
track: snapshot.data!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).paddingSymmetric(horizontal: 24),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,17 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
import 'package:rhythm_box/screens/player/queue.dart';
|
import 'package:rhythm_box/screens/player/queue.dart';
|
||||||
|
import 'package:rhythm_box/screens/player/siblings.dart';
|
||||||
|
import 'package:rhythm_box/screens/player/source_details.dart';
|
||||||
import 'package:rhythm_box/services/artist.dart';
|
import 'package:rhythm_box/services/artist.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/services/duration.dart';
|
||||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
import 'package:rhythm_box/services/audio_services/image.dart';
|
import 'package:rhythm_box/services/audio_services/image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/heart_button.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
|
||||||
class PlayerScreen extends StatefulWidget {
|
class PlayerScreen extends StatefulWidget {
|
||||||
@@ -26,6 +32,7 @@ class PlayerScreen extends StatefulWidget {
|
|||||||
class _PlayerScreenState extends State<PlayerScreen> {
|
class _PlayerScreenState extends State<PlayerScreen> {
|
||||||
late final AudioPlayerProvider _playback = Get.find();
|
late final AudioPlayerProvider _playback = Get.find();
|
||||||
late final QueryingTrackInfoProvider _query = Get.find();
|
late final QueryingTrackInfoProvider _query = Get.find();
|
||||||
|
late final AuthenticationProvider _auth = Get.find();
|
||||||
|
|
||||||
String? get _albumArt =>
|
String? get _albumArt =>
|
||||||
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
||||||
@@ -37,13 +44,6 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
|||||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||||
PlaylistMode get _loopMode => _playback.state.value.loopMode;
|
PlaylistMode get _loopMode => _playback.state.value.loopMode;
|
||||||
|
|
||||||
double _bufferProgress = 0;
|
|
||||||
|
|
||||||
Duration _durationCurrent = Duration.zero;
|
|
||||||
Duration _durationTotal = Duration.zero;
|
|
||||||
|
|
||||||
List<StreamSubscription>? _subscriptions;
|
|
||||||
|
|
||||||
Future<void> _togglePlayState() async {
|
Future<void> _togglePlayState() async {
|
||||||
if (!audioPlayer.isPlaying) {
|
if (!audioPlayer.isPlaying) {
|
||||||
await audioPlayer.resume();
|
await audioPlayer.resume();
|
||||||
@@ -53,45 +53,16 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
|
||||||
String negativeSign = duration.isNegative ? '-' : '';
|
|
||||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
|
||||||
String twoDigitMinutes = twoDigits(duration.inMinutes.abs());
|
|
||||||
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
|
|
||||||
return '$negativeSign$twoDigitMinutes:$twoDigitSeconds';
|
|
||||||
}
|
|
||||||
|
|
||||||
double? _draggingValue;
|
double? _draggingValue;
|
||||||
|
|
||||||
@override
|
static const double maxAlbumSize = 360;
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_durationCurrent = audioPlayer.position;
|
|
||||||
_durationTotal = audioPlayer.duration;
|
|
||||||
_bufferProgress = audioPlayer.bufferedPosition.inMilliseconds.toDouble();
|
|
||||||
_subscriptions = [
|
|
||||||
audioPlayer.durationStream
|
|
||||||
.listen((dur) => setState(() => _durationTotal = dur)),
|
|
||||||
audioPlayer.positionStream
|
|
||||||
.listen((dur) => setState(() => _durationCurrent = dur)),
|
|
||||||
audioPlayer.bufferedPositionStream.listen((dur) =>
|
|
||||||
setState(() => _bufferProgress = dur.inMilliseconds.toDouble())),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
if (_subscriptions != null) {
|
|
||||||
for (final subscription in _subscriptions!) {
|
|
||||||
subscription.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
|
final albumSize = max(size.shortestSide, maxAlbumSize).toDouble();
|
||||||
|
|
||||||
|
final isLargeScreen = size.width >= 720;
|
||||||
|
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
@@ -99,217 +70,346 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
direction: DismissiblePageDismissDirection.down,
|
direction: DismissiblePageDismissDirection.down,
|
||||||
child: Material(
|
child: Scaffold(
|
||||||
color: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
child: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
Expanded(
|
||||||
tag: const Key('current-active-track-album-art'),
|
child: ListView(
|
||||||
child: ClipRRect(
|
shrinkWrap: true,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1,
|
|
||||||
child: _albumArt != null
|
|
||||||
? AutoCacheImage(
|
|
||||||
_albumArt!,
|
|
||||||
width: size.width,
|
|
||||||
height: size.width,
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHigh,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
child: const Center(child: Icon(Icons.image)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).marginSymmetric(horizontal: 24),
|
|
||||||
),
|
|
||||||
const Gap(24),
|
|
||||||
Text(
|
|
||||||
_playback.state.value.activeTrack?.name ?? 'Not playing',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_playback.state.value.activeTrack?.artists?.asString() ??
|
|
||||||
'No author',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const Gap(24),
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
SliderTheme(
|
|
||||||
data: SliderThemeData(
|
|
||||||
trackHeight: 2,
|
|
||||||
trackShape: _PlayerProgressTrackShape(),
|
|
||||||
thumbShape: const RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: 8,
|
|
||||||
),
|
|
||||||
overlayShape: SliderComponentShape.noOverlay,
|
|
||||||
),
|
|
||||||
child: Slider(
|
|
||||||
secondaryTrackValue: _bufferProgress.abs(),
|
|
||||||
value: _draggingValue?.abs() ??
|
|
||||||
_durationCurrent.inMilliseconds.toDouble().abs(),
|
|
||||||
min: 0,
|
|
||||||
max: max(
|
|
||||||
_durationTotal.inMilliseconds.abs(),
|
|
||||||
_durationTotal.inMilliseconds.abs(),
|
|
||||||
).toDouble(),
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _draggingValue = value);
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) {
|
|
||||||
print('Seek to $value ms');
|
|
||||||
audioPlayer.seek(Duration(milliseconds: value.toInt()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Obx(
|
||||||
_formatDuration(_durationCurrent),
|
() => Center(
|
||||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
child: LimitedBox(
|
||||||
),
|
maxHeight: maxAlbumSize,
|
||||||
Text(
|
maxWidth: maxAlbumSize,
|
||||||
_formatDuration(_durationTotal),
|
child: Hero(
|
||||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
tag: const Key('current-active-track-album-art'),
|
||||||
),
|
child: AspectRatio(
|
||||||
],
|
aspectRatio: 1,
|
||||||
).paddingSymmetric(horizontal: 8, vertical: 4),
|
child: ClipRRect(
|
||||||
],
|
borderRadius: const BorderRadius.all(
|
||||||
).paddingSymmetric(horizontal: 24),
|
Radius.circular(16),
|
||||||
const Gap(24),
|
),
|
||||||
Row(
|
child: _albumArt != null
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
? AutoCacheImage(
|
||||||
children: [
|
_albumArt!,
|
||||||
StreamBuilder<bool>(
|
width: albumSize,
|
||||||
stream: audioPlayer.shuffledStream,
|
height: albumSize,
|
||||||
builder: (context, snapshot) {
|
fit: BoxFit.cover,
|
||||||
final shuffled = snapshot.data ?? false;
|
)
|
||||||
return IconButton(
|
: Container(
|
||||||
icon: Icon(
|
color: Theme.of(context)
|
||||||
shuffled ? Icons.shuffle_on_outlined : Icons.shuffle,
|
.colorScheme
|
||||||
),
|
.surfaceContainerHigh,
|
||||||
onPressed: _isFetchingActiveTrack
|
width: 64,
|
||||||
? null
|
height: 64,
|
||||||
: () {
|
child: const Center(
|
||||||
if (shuffled) {
|
child: Icon(Icons.image),
|
||||||
audioPlayer.setShuffle(false);
|
),
|
||||||
} else {
|
),
|
||||||
audioPlayer.setShuffle(true);
|
),
|
||||||
}
|
).marginSymmetric(horizontal: 24),
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.skip_previous),
|
|
||||||
onPressed: _isFetchingActiveTrack
|
|
||||||
? null
|
|
||||||
: audioPlayer.skipToPrevious,
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
SizedBox(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
child: IconButton.filled(
|
|
||||||
icon: _isFetchingActiveTrack
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.white,
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
|
||||||
size: 28,
|
|
||||||
),
|
),
|
||||||
onPressed:
|
),
|
||||||
_isFetchingActiveTrack ? null : _togglePlayState,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.skip_next),
|
|
||||||
onPressed:
|
|
||||||
_isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
|
||||||
),
|
|
||||||
Obx(
|
|
||||||
() => IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_loopMode == PlaylistMode.none
|
|
||||||
? Icons.repeat
|
|
||||||
: _loopMode == PlaylistMode.loop
|
|
||||||
? Icons.repeat_on_outlined
|
|
||||||
: Icons.repeat_one_on_outlined,
|
|
||||||
),
|
),
|
||||||
onPressed: _isFetchingActiveTrack
|
const Gap(24),
|
||||||
? null
|
Obx(
|
||||||
: () async {
|
() => Row(
|
||||||
await audioPlayer.setLoopMode(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
switch (_loopMode) {
|
children: [
|
||||||
PlaylistMode.loop => PlaylistMode.single,
|
Expanded(
|
||||||
PlaylistMode.single => PlaylistMode.none,
|
child: Column(
|
||||||
PlaylistMode.none => PlaylistMode.loop,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_playback.state.value.activeTrack?.name ??
|
||||||
|
'Not playing',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_playback.state.value.activeTrack?.artists
|
||||||
|
?.asString() ??
|
||||||
|
'No author',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodyMedium,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_playback.state.value.activeTrack != null &&
|
||||||
|
_auth.auth.value != null)
|
||||||
|
TrackHeartButton(
|
||||||
|
key: ValueKey(
|
||||||
|
_playback.state.value.activeTrack!.id!,
|
||||||
|
),
|
||||||
|
trackId: _playback.state.value.activeTrack!.id!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 32),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
Obx(
|
||||||
|
() => Column(
|
||||||
|
children: [
|
||||||
|
SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
trackHeight: 2,
|
||||||
|
trackShape: _PlayerProgressTrackShape(),
|
||||||
|
thumbShape: const RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: 8,
|
||||||
|
),
|
||||||
|
overlayShape: SliderComponentShape.noOverlay,
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
secondaryTrackValue: _playback
|
||||||
|
.durationBuffered.value.inMilliseconds
|
||||||
|
.abs()
|
||||||
|
.toDouble(),
|
||||||
|
value: _draggingValue?.abs() ??
|
||||||
|
_playback
|
||||||
|
.durationCurrent.value.inMilliseconds
|
||||||
|
.toDouble()
|
||||||
|
.abs(),
|
||||||
|
min: 0,
|
||||||
|
max: max(
|
||||||
|
_playback.durationCurrent.value.inMilliseconds
|
||||||
|
.abs(),
|
||||||
|
_playback.durationTotal.value.inMilliseconds
|
||||||
|
.abs(),
|
||||||
|
).toDouble(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _draggingValue = value);
|
||||||
},
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
audioPlayer.seek(
|
||||||
|
Duration(milliseconds: value.toInt()),
|
||||||
|
);
|
||||||
|
setState(() => _draggingValue = null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_playback.durationCurrent.value
|
||||||
|
.toHumanReadableString(),
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_playback.durationTotal.value
|
||||||
|
.toHumanReadableString(),
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 8, vertical: 4),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 24),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
StreamBuilder<bool>(
|
||||||
|
stream: audioPlayer.shuffledStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final shuffled = snapshot.data ?? false;
|
||||||
|
return Obx(
|
||||||
|
() => IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
shuffled
|
||||||
|
? Icons.shuffle_on_outlined
|
||||||
|
: Icons.shuffle,
|
||||||
|
),
|
||||||
|
onPressed: _isFetchingActiveTrack
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (shuffled) {
|
||||||
|
audioPlayer.setShuffle(false);
|
||||||
|
} else {
|
||||||
|
audioPlayer.setShuffle(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Obx(
|
||||||
|
() => IconButton(
|
||||||
|
icon: const Icon(Icons.skip_previous),
|
||||||
|
onPressed: _isFetchingActiveTrack
|
||||||
|
? null
|
||||||
|
: audioPlayer.skipToPrevious,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Obx(
|
||||||
|
() => SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: IconButton.filled(
|
||||||
|
icon: _isFetchingActiveTrack
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
!_isPlaying
|
||||||
|
? Icons.play_arrow
|
||||||
|
: Icons.pause,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: _togglePlayState,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Obx(
|
||||||
|
() => IconButton(
|
||||||
|
icon: const Icon(Icons.skip_next),
|
||||||
|
onPressed: _isFetchingActiveTrack
|
||||||
|
? null
|
||||||
|
: audioPlayer.skipToNext,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(
|
||||||
|
() => IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_loopMode == PlaylistMode.none
|
||||||
|
? Icons.repeat
|
||||||
|
: _loopMode == PlaylistMode.loop
|
||||||
|
? Icons.repeat_on_outlined
|
||||||
|
: Icons.repeat_one_on_outlined,
|
||||||
|
),
|
||||||
|
onPressed: _isFetchingActiveTrack
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await audioPlayer.setLoopMode(
|
||||||
|
switch (_loopMode) {
|
||||||
|
PlaylistMode.loop =>
|
||||||
|
PlaylistMode.single,
|
||||||
|
PlaylistMode.single =>
|
||||||
|
PlaylistMode.none,
|
||||||
|
PlaylistMode.none =>
|
||||||
|
PlaylistMode.loop,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(20),
|
||||||
|
Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.queue_music),
|
||||||
|
label: const Text(
|
||||||
|
'Queue',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
const PlayerQueuePopup(),
|
||||||
|
).then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!isLargeScreen) const Gap(4),
|
||||||
|
if (!isLargeScreen)
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.lyrics),
|
||||||
|
label: const Text(
|
||||||
|
'Lyrics',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter.of(context)
|
||||||
|
.pushNamed('playerLyrics');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.merge),
|
||||||
|
label: const Text(
|
||||||
|
'Sources',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
const SiblingTracksPopup(),
|
||||||
|
).then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
TextButton.icon(
|
||||||
|
label: const Text('Info'),
|
||||||
|
icon: const Icon(Icons.info),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
const SourceDetailsPopup(),
|
||||||
|
).then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
if (isLargeScreen) const Gap(24),
|
||||||
const Gap(20),
|
if (isLargeScreen)
|
||||||
Row(
|
const Expanded(
|
||||||
children: [
|
child: SyncedLyrics(defaultTextZoom: 67),
|
||||||
Expanded(
|
)
|
||||||
child: TextButton.icon(
|
],
|
||||||
icon: const Icon(Icons.queue_music),
|
),
|
||||||
label: const Text('Queue'),
|
|
||||||
onPressed: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
isScrollControlled: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) => const PlayerQueuePopup(),
|
|
||||||
).then((_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Expanded(
|
|
||||||
child: TextButton.icon(
|
|
||||||
icon: const Icon(Icons.lyrics),
|
|
||||||
label: const Text('Lyrics'),
|
|
||||||
onPressed: () {
|
|
||||||
GoRouter.of(context).pushNamed('playerLyrics');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Expanded(
|
|
||||||
child: TextButton.icon(
|
|
||||||
icon: const Icon(Icons.merge),
|
|
||||||
label: const Text('Sources'),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
).marginAll(24),
|
).marginSymmetric(horizontal: 24),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:rhythm_box/providers/history.dart';
|
|||||||
import 'package:rhythm_box/providers/spotify.dart';
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
|
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
@@ -36,19 +37,46 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isLoadingTracks = true;
|
||||||
bool _isUpdating = false;
|
bool _isUpdating = false;
|
||||||
|
|
||||||
Playlist? _playlist;
|
Playlist? _playlist;
|
||||||
|
List<Track>? _tracks;
|
||||||
|
|
||||||
Future<void> _pullPlaylist() async {
|
Future<void> _pullPlaylist() async {
|
||||||
_playlist = await _spotify.api.playlists.get(widget.playlistId);
|
if (widget.playlistId == 'user-liked-tracks') {
|
||||||
|
_playlist = Playlist()
|
||||||
|
..name = 'Liked Music'
|
||||||
|
..description = 'Your favorite music'
|
||||||
|
..type = 'playlist'
|
||||||
|
..collaborative = false
|
||||||
|
..public = false
|
||||||
|
..id = 'user-liked-tracks';
|
||||||
|
} else {
|
||||||
|
_playlist = await _spotify.api.playlists.get(widget.playlistId);
|
||||||
|
}
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pullTracks() async {
|
||||||
|
if (widget.playlistId == 'user-liked-tracks') {
|
||||||
|
_tracks = (await _spotify.api.tracks.me.saved.all())
|
||||||
|
.map((x) => x.track!)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
_tracks = (await _spotify.api.playlists
|
||||||
|
.getTracksByPlaylistId(widget.playlistId)
|
||||||
|
.all())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
setState(() => _isLoadingTracks = false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pullPlaylist();
|
_pullPlaylist();
|
||||||
|
_pullTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -60,6 +88,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Playlist'),
|
title: const Text('Playlist'),
|
||||||
|
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||||
),
|
),
|
||||||
body: Builder(
|
body: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -69,95 +98,117 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomScrollView(
|
return CenteredContainer(
|
||||||
slivers: [
|
child: CustomScrollView(
|
||||||
SliverToBoxAdapter(
|
slivers: [
|
||||||
child: Column(
|
SliverToBoxAdapter(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Material(
|
children: [
|
||||||
borderRadius: radius,
|
Material(
|
||||||
elevation: 2,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: Hero(
|
elevation: 2,
|
||||||
tag: Key('playlist-cover-${_playlist!.id}'),
|
child: ClipRRect(
|
||||||
child: AutoCacheImage(
|
borderRadius: radius,
|
||||||
_playlist!.images!.first.url!,
|
child: (_playlist?.images?.isNotEmpty ?? false)
|
||||||
width: 160.0,
|
? AutoCacheImage(
|
||||||
height: 160.0,
|
_playlist!.images!.first.url!,
|
||||||
),
|
width: 160.0,
|
||||||
|
height: 160.0,
|
||||||
|
)
|
||||||
|
: const SizedBox(
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
child: Icon(Icons.image),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const Gap(24),
|
||||||
const Gap(24),
|
Expanded(
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
_playlist!.name ?? 'Playlist',
|
||||||
_playlist!.name ?? 'Playlist',
|
style: Theme.of(context)
|
||||||
style:
|
.textTheme
|
||||||
Theme.of(context).textTheme.headlineSmall,
|
.headlineSmall,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.fade,
|
overflow: TextOverflow.fade,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_playlist!.description ?? 'A Playlist',
|
_playlist!.description ?? 'A Playlist',
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text(
|
Text(
|
||||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers!.total!)} saves",
|
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers?.total! ?? 0)} saves",
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'#${_playlist!.id}',
|
'#${_playlist!.id}',
|
||||||
style: GoogleFonts.robotoMono(fontSize: 10),
|
style: GoogleFonts.robotoMono(fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
).paddingOnly(left: 24, right: 24, top: 24),
|
||||||
).paddingOnly(left: 24, right: 24, top: 24),
|
const Gap(8),
|
||||||
const Gap(8),
|
Wrap(
|
||||||
Wrap(
|
spacing: 8,
|
||||||
spacing: 8,
|
children: [
|
||||||
children: [
|
Obx(
|
||||||
Obx(
|
() => ElevatedButton.icon(
|
||||||
() => ElevatedButton.icon(
|
icon: (_isCurrentPlaylist &&
|
||||||
icon: (_isCurrentPlaylist &&
|
_playback.isPlaying.value)
|
||||||
_playback.isPlaying.value)
|
? const Icon(Icons.pause_outlined)
|
||||||
? const Icon(Icons.pause_outlined)
|
: const Icon(Icons.play_arrow),
|
||||||
: const Icon(Icons.play_arrow),
|
label: const Text('Play'),
|
||||||
label: const Text('Play'),
|
onPressed: _isUpdating
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
if (_isCurrentPlaylist &&
|
||||||
|
_playback.isPlaying.value) {
|
||||||
|
audioPlayer.pause();
|
||||||
|
return;
|
||||||
|
} else if (_isCurrentPlaylist &&
|
||||||
|
!_playback.isPlaying.value) {
|
||||||
|
audioPlayer.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isUpdating = true);
|
||||||
|
|
||||||
|
await _playback.load(_tracks!,
|
||||||
|
autoPlay: true);
|
||||||
|
_playback.addCollection(_playlist!.id!);
|
||||||
|
Get.find<PlaybackHistoryProvider>()
|
||||||
|
.addPlaylists([_playlist!]);
|
||||||
|
|
||||||
|
setState(() => _isUpdating = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.shuffle),
|
||||||
|
label: const Text('Shuffle'),
|
||||||
onPressed: _isUpdating
|
onPressed: _isUpdating
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
if (_isCurrentPlaylist &&
|
|
||||||
_playback.isPlaying.value) {
|
|
||||||
audioPlayer.pause();
|
|
||||||
return;
|
|
||||||
} else if (_isCurrentPlaylist &&
|
|
||||||
!_playback.isPlaying.value) {
|
|
||||||
audioPlayer.resume();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _isUpdating = true);
|
setState(() => _isUpdating = true);
|
||||||
|
|
||||||
final tracks = (await _spotify
|
audioPlayer.setShuffle(true);
|
||||||
.api.playlists
|
|
||||||
.getTracksByPlaylistId(
|
|
||||||
widget.playlistId)
|
|
||||||
.all())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
await _playback.load(tracks,
|
await _playback.load(
|
||||||
autoPlay: true);
|
_tracks!,
|
||||||
|
autoPlay: true,
|
||||||
|
initialIndex:
|
||||||
|
Random().nextInt(_tracks!.length),
|
||||||
|
);
|
||||||
_playback.addCollection(_playlist!.id!);
|
_playback.addCollection(_playlist!.id!);
|
||||||
Get.find<PlaybackHistoryProvider>()
|
Get.find<PlaybackHistoryProvider>()
|
||||||
.addPlaylists([_playlist!]);
|
.addPlaylists([_playlist!]);
|
||||||
@@ -165,50 +216,25 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
|||||||
setState(() => _isUpdating = false);
|
setState(() => _isUpdating = false);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
TextButton.icon(
|
).paddingSymmetric(horizontal: 24),
|
||||||
icon: const Icon(Icons.shuffle),
|
const Gap(24),
|
||||||
label: const Text('Shuffle'),
|
],
|
||||||
onPressed: _isUpdating
|
),
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
setState(() => _isUpdating = true);
|
|
||||||
|
|
||||||
audioPlayer.setShuffle(true);
|
|
||||||
|
|
||||||
final tracks = (await _spotify.api.playlists
|
|
||||||
.getTracksByPlaylistId(
|
|
||||||
widget.playlistId)
|
|
||||||
.all())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
await _playback.load(
|
|
||||||
tracks,
|
|
||||||
autoPlay: true,
|
|
||||||
initialIndex:
|
|
||||||
Random().nextInt(tracks.length),
|
|
||||||
);
|
|
||||||
_playback.addCollection(_playlist!.id!);
|
|
||||||
Get.find<PlaybackHistoryProvider>()
|
|
||||||
.addPlaylists([_playlist!]);
|
|
||||||
|
|
||||||
setState(() => _isUpdating = false);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).paddingSymmetric(horizontal: 24),
|
|
||||||
const Gap(24),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: Text(
|
||||||
child: Text(
|
'Songs (${_playlist!.tracks?.total ?? (_tracks?.length ?? 0)})',
|
||||||
'Songs (${_playlist!.tracks!.total})',
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||||
).paddingOnly(left: 28, right: 28, bottom: 4),
|
),
|
||||||
),
|
PlaylistTrackList(
|
||||||
PlaylistTrackList(playlistId: widget.playlistId),
|
isLoading: _isLoadingTracks,
|
||||||
],
|
playlistId: widget.playlistId,
|
||||||
|
tracks: _tracks,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:rhythm_box/providers/spotify.dart';
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/track_list.dart';
|
import 'package:rhythm_box/widgets/tracks/track_list.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
@@ -61,11 +62,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
FocusManager.instance.primaryFocus?.unfocus(),
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
).paddingSymmetric(horizontal: 24, vertical: 8),
|
).paddingSymmetric(horizontal: 24, vertical: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: CenteredContainer(
|
||||||
slivers: [
|
child: CustomScrollView(
|
||||||
if (_searchResult != null)
|
slivers: [
|
||||||
TrackSliverList(tracks: List<Track>.from(_searchResult!)),
|
if (_searchResult != null)
|
||||||
],
|
TrackSliverList(tracks: List<Track>.from(_searchResult!)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
|
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/screens/auth/login.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||||
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -8,8 +19,255 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
late final SpotifyProvider _spotify = Get.find();
|
||||||
|
late final AuthenticationProvider _authenticate = Get.find();
|
||||||
|
late final UserPreferencesProvider _preferences = Get.find();
|
||||||
|
|
||||||
|
bool _isLoggingIn = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Placeholder();
|
return Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||||
|
),
|
||||||
|
body: CenteredContainer(
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
Obx(() {
|
||||||
|
if (_authenticate.auth.value == null) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Icons.login),
|
||||||
|
title: const Text('Connect with Spotify'),
|
||||||
|
subtitle:
|
||||||
|
const Text('To explore your own library and more'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
enabled: !_isLoggingIn,
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _isLoggingIn = true);
|
||||||
|
await universalLogin(context);
|
||||||
|
setState(() => _isLoggingIn = false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _spotify.api.me.get(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text('Loading...'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: (snapshot.data!.images?.isNotEmpty ?? false)
|
||||||
|
? CircleAvatar(
|
||||||
|
backgroundImage: AutoCacheImage.provider(
|
||||||
|
snapshot.data!.images!.firstOrNull!.url!,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.account_circle),
|
||||||
|
title: Text(snapshot.data!.displayName!),
|
||||||
|
subtitle: const Text('Connected with your Spotify'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Obx(() {
|
||||||
|
if (_authenticate.auth.value == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
if (_authenticate.auth.value?.neteaseCookie == null) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Icons.cloud_outlined),
|
||||||
|
title: const Text('Connect with Netease Cloud Music'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Make us able to play more music from Netease Cloud Music'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
enabled: !_isLoggingIn,
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _isLoggingIn = true);
|
||||||
|
await GoRouter.of(context)
|
||||||
|
.pushNamed('authMobileLoginNetease');
|
||||||
|
setState(() => _isLoggingIn = false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder(
|
||||||
|
future: NeteaseSourcedTrack.getClient().get('/user/account'),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: const Text('Loading...'),
|
||||||
|
trailing: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
_authenticate.logoutNetease();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.link_off),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading:
|
||||||
|
snapshot.data!.body['profile']['avatarUrl'] != null
|
||||||
|
? CircleAvatar(
|
||||||
|
backgroundImage: AutoCacheImage.provider(
|
||||||
|
snapshot.data!.body['profile']['avatarUrl'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.account_circle),
|
||||||
|
title: Text(snapshot.data!.body['profile']['nickname']),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Connected with your Netease Cloud Music account',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
_authenticate.logoutNetease();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.link_off),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Obx(() {
|
||||||
|
if (_authenticate.auth.value == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: const Text('Log out'),
|
||||||
|
subtitle: const Text('Disconnect with every account'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
_authenticate.logout();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Divider(thickness: 0.3, height: 1),
|
||||||
|
Obx(
|
||||||
|
() => ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Icons.audio_file),
|
||||||
|
title: const Text('Audio Source'),
|
||||||
|
subtitle:
|
||||||
|
const Text('Choose who to provide the songs you played.'),
|
||||||
|
trailing: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton2<AudioSource>(
|
||||||
|
isExpanded: true,
|
||||||
|
hint: Text(
|
||||||
|
'Select Item',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: AudioSource.values
|
||||||
|
.map((AudioSource item) =>
|
||||||
|
DropdownMenuItem<AudioSource>(
|
||||||
|
value: item,
|
||||||
|
child: Text(
|
||||||
|
item.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
value: _preferences.state.value.audioSource,
|
||||||
|
onChanged: (AudioSource? value) {
|
||||||
|
_preferences
|
||||||
|
.setAudioSource(value ?? AudioSource.youtube);
|
||||||
|
},
|
||||||
|
buttonStyleData: const ButtonStyleData(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
height: 40,
|
||||||
|
width: 140,
|
||||||
|
),
|
||||||
|
menuItemStyleData: const MenuItemStyleData(
|
||||||
|
height: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(thickness: 0.3, height: 1),
|
||||||
|
Obx(
|
||||||
|
() => SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
secondary: const Icon(Icons.all_inclusive),
|
||||||
|
title: const Text('Endless Playback'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Automatically get more recommendation for you after your queue finish playing'),
|
||||||
|
value: _preferences.state.value.endlessPlayback,
|
||||||
|
onChanged: _preferences.setEndlessPlayback,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(
|
||||||
|
() => SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
secondary: const Icon(Icons.graphic_eq),
|
||||||
|
title: const Text('Normalize Audio'),
|
||||||
|
subtitle:
|
||||||
|
const Text('Make audio not too loud either too quiet'),
|
||||||
|
value: _preferences.state.value.normalizeAudio,
|
||||||
|
onChanged: _preferences.setNormalizeAudio,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(
|
||||||
|
() => SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
secondary: const Icon(Icons.screen_lock_portrait),
|
||||||
|
title: const Text('Player Wakelock'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Keep your screen doesn\'t lock in player screen'),
|
||||||
|
value: _preferences.state.value.playerWakelock,
|
||||||
|
onChanged: _preferences.setPlayerWakelock,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(thickness: 0.3, height: 1),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Icons.info),
|
||||||
|
title: const Text('About'),
|
||||||
|
subtitle: const Text('More information about this app'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
GoRouter.of(context).pushNamed('about');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
lib/services/album.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
extension AlbumExtensions on AlbumSimple {
|
||||||
|
Album toAlbum() {
|
||||||
|
Album album = Album();
|
||||||
|
album.albumType = albumType;
|
||||||
|
album.artists = artists;
|
||||||
|
album.availableMarkets = availableMarkets;
|
||||||
|
album.externalUrls = externalUrls;
|
||||||
|
album.href = href;
|
||||||
|
album.id = id;
|
||||||
|
album.images = images;
|
||||||
|
album.name = name;
|
||||||
|
album.releaseDate = releaseDate;
|
||||||
|
album.releaseDatePrecision = releaseDatePrecision;
|
||||||
|
album.tracks = tracks;
|
||||||
|
album.type = type;
|
||||||
|
album.uri = uri;
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:media_kit/media_kit.dart' hide Track;
|
import 'package:media_kit/media_kit.dart' hide Track;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:rhythm_box/platform.dart';
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/local_track.dart';
|
import 'package:rhythm_box/services/local_track.dart';
|
||||||
import 'package:rhythm_box/services/server/server.dart';
|
import 'package:rhythm_box/services/server/server.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
@@ -93,7 +94,7 @@ abstract class AudioPlayerInterface {
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
_mkPlayer.stream.error.listen((event) {
|
_mkPlayer.stream.error.listen((event) {
|
||||||
log('[Playback] Error: $event');
|
Get.find<ErrorNotifier>().logError('[Playback][Player] Error: $event');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,16 +90,28 @@ class RhythmAudioPlayer extends AudioPlayerInterface
|
|||||||
|
|
||||||
Future<void> skipToNext() async {
|
Future<void> skipToNext() async {
|
||||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||||
|
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
|
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
await _mkPlayer.next();
|
await _mkPlayer.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipToPrevious() async {
|
Future<void> skipToPrevious() async {
|
||||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||||
|
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
|
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
await _mkPlayer.previous();
|
await _mkPlayer.previous();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpTo(int index) async {
|
Future<void> jumpTo(int index) async {
|
||||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||||
|
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
|
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||||
|
const Duration(seconds: 0);
|
||||||
await _mkPlayer.jump(index);
|
await _mkPlayer.jump(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'package:get/get.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:rhythm_box/platform.dart';
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
|
|
||||||
// ignore: implementation_imports
|
// ignore: implementation_imports
|
||||||
import 'package:rhythm_box/services/audio_player/playback_state.dart';
|
import 'package:rhythm_box/services/audio_player/playback_state.dart';
|
||||||
@@ -49,7 +50,8 @@ class CustomPlayer extends Player {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
stream.error.listen((event) {
|
stream.error.listen((event) {
|
||||||
log('[MediaKitError] $event');
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[Playback][CustomLayer] Error: $event');
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
PackageInfo.fromPlatform().then((packageInfo) {
|
PackageInfo.fromPlatform().then((packageInfo) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class WindowsAudioService {
|
|||||||
final subscriptions = <StreamSubscription>[];
|
final subscriptions = <StreamSubscription>[];
|
||||||
|
|
||||||
WindowsAudioService() : smtc = SMTCWindows(enabled: false) {
|
WindowsAudioService() : smtc = SMTCWindows(enabled: false) {
|
||||||
smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
smtc.setPlaybackStatus(PlaybackStatus.stopped);
|
||||||
final buttonStream = smtc.buttonPressStream.listen((event) {
|
final buttonStream = smtc.buttonPressStream.listen((event) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case PressedButton.play:
|
case PressedButton.play:
|
||||||
@@ -42,16 +42,16 @@ class WindowsAudioService {
|
|||||||
audioPlayer.playerStateStream.listen((state) async {
|
audioPlayer.playerStateStream.listen((state) async {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case AudioPlaybackState.playing:
|
case AudioPlaybackState.playing:
|
||||||
await smtc.setPlaybackStatus(PlaybackStatus.Playing);
|
await smtc.setPlaybackStatus(PlaybackStatus.playing);
|
||||||
break;
|
break;
|
||||||
case AudioPlaybackState.paused:
|
case AudioPlaybackState.paused:
|
||||||
await smtc.setPlaybackStatus(PlaybackStatus.Paused);
|
await smtc.setPlaybackStatus(PlaybackStatus.paused);
|
||||||
break;
|
break;
|
||||||
case AudioPlaybackState.stopped:
|
case AudioPlaybackState.stopped:
|
||||||
await smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
await smtc.setPlaybackStatus(PlaybackStatus.stopped);
|
||||||
break;
|
break;
|
||||||
case AudioPlaybackState.completed:
|
case AudioPlaybackState.completed:
|
||||||
await smtc.setPlaybackStatus(PlaybackStatus.Changing);
|
await smtc.setPlaybackStatus(PlaybackStatus.changing);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration {
|
||||||
|
return MigrationStrategy(
|
||||||
|
onCreate: (Migrator m) async {
|
||||||
|
await m.createAll();
|
||||||
|
},
|
||||||
|
onUpgrade: (Migrator m, int from, int to) async {},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ part of '../database.dart';
|
|||||||
|
|
||||||
class AuthenticationTable extends Table {
|
class AuthenticationTable extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
TextColumn get cookie => text().map(EncryptedTextConverter())();
|
TextColumn get spotifyCookie => text().map(EncryptedTextConverter())();
|
||||||
TextColumn get accessToken => text().map(EncryptedTextConverter())();
|
TextColumn get spotifyAccessToken => text().map(EncryptedTextConverter())();
|
||||||
DateTimeColumn get expiration => dateTime()();
|
DateTimeColumn get spotifyExpiration => dateTime()();
|
||||||
|
TextColumn get neteaseCookie =>
|
||||||
|
text().map(EncryptedTextConverter()).nullable()();
|
||||||
|
DateTimeColumn get neteaseExpiration => dateTime().nullable()();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ enum CloseBehavior {
|
|||||||
|
|
||||||
enum AudioSource {
|
enum AudioSource {
|
||||||
youtube,
|
youtube,
|
||||||
piped;
|
piped,
|
||||||
|
netease,
|
||||||
|
kugou;
|
||||||
|
|
||||||
String get label => name[0].toUpperCase() + name.substring(1);
|
String get label => name[0].toUpperCase() + name.substring(1);
|
||||||
}
|
}
|
||||||
@@ -45,8 +47,6 @@ class PreferencesTable extends Table {
|
|||||||
.withDefault(Constant(SourceQualities.high.name))();
|
.withDefault(Constant(SourceQualities.high.name))();
|
||||||
BoolColumn get albumColorSync =>
|
BoolColumn get albumColorSync =>
|
||||||
boolean().withDefault(const Constant(true))();
|
boolean().withDefault(const Constant(true))();
|
||||||
BoolColumn get amoledDarkTheme =>
|
|
||||||
boolean().withDefault(const Constant(false))();
|
|
||||||
BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))();
|
BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))();
|
||||||
BoolColumn get normalizeAudio =>
|
BoolColumn get normalizeAudio =>
|
||||||
boolean().withDefault(const Constant(false))();
|
boolean().withDefault(const Constant(false))();
|
||||||
@@ -76,6 +76,8 @@ class PreferencesTable extends Table {
|
|||||||
text().withDefault(const Constant('')).map(const StringListConverter())();
|
text().withDefault(const Constant('')).map(const StringListConverter())();
|
||||||
TextColumn get pipedInstance =>
|
TextColumn get pipedInstance =>
|
||||||
text().withDefault(const Constant('https://pipedapi.kavin.rocks'))();
|
text().withDefault(const Constant('https://pipedapi.kavin.rocks'))();
|
||||||
|
TextColumn get neteaseApiInstance => text().withDefault(
|
||||||
|
const Constant('https://rhythmbox-netease-music-api.vercel.app'))();
|
||||||
TextColumn get themeMode =>
|
TextColumn get themeMode =>
|
||||||
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
||||||
TextColumn get audioSource =>
|
TextColumn get audioSource =>
|
||||||
@@ -84,12 +86,10 @@ class PreferencesTable extends Table {
|
|||||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
|
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
|
||||||
TextColumn get downloadMusicCodec =>
|
TextColumn get downloadMusicCodec =>
|
||||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
|
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
|
||||||
BoolColumn get discordPresence =>
|
|
||||||
boolean().withDefault(const Constant(true))();
|
|
||||||
BoolColumn get endlessPlayback =>
|
BoolColumn get endlessPlayback =>
|
||||||
boolean().withDefault(const Constant(true))();
|
boolean().withDefault(const Constant(true))();
|
||||||
BoolColumn get enableConnect =>
|
BoolColumn get playerWakelock =>
|
||||||
boolean().withDefault(const Constant(false))();
|
boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
// Default values as PreferencesTableData
|
// Default values as PreferencesTableData
|
||||||
static PreferencesTableData defaults() {
|
static PreferencesTableData defaults() {
|
||||||
@@ -97,7 +97,6 @@ class PreferencesTable extends Table {
|
|||||||
id: 0,
|
id: 0,
|
||||||
audioQuality: SourceQualities.high,
|
audioQuality: SourceQualities.high,
|
||||||
albumColorSync: true,
|
albumColorSync: true,
|
||||||
amoledDarkTheme: false,
|
|
||||||
checkUpdate: true,
|
checkUpdate: true,
|
||||||
normalizeAudio: false,
|
normalizeAudio: false,
|
||||||
showSystemTrayIcon: false,
|
showSystemTrayIcon: false,
|
||||||
@@ -111,14 +110,14 @@ class PreferencesTable extends Table {
|
|||||||
searchMode: SearchMode.youtube,
|
searchMode: SearchMode.youtube,
|
||||||
downloadLocation: '',
|
downloadLocation: '',
|
||||||
localLibraryLocation: [],
|
localLibraryLocation: [],
|
||||||
|
neteaseApiInstance: 'https://rhythmbox-netease-music-api.vercel.app',
|
||||||
pipedInstance: 'https://pipedapi.kavin.rocks',
|
pipedInstance: 'https://pipedapi.kavin.rocks',
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
audioSource: AudioSource.youtube,
|
audioSource: AudioSource.youtube,
|
||||||
streamMusicCodec: SourceCodecs.weba,
|
streamMusicCodec: SourceCodecs.weba,
|
||||||
downloadMusicCodec: SourceCodecs.m4a,
|
downloadMusicCodec: SourceCodecs.m4a,
|
||||||
discordPresence: true,
|
|
||||||
endlessPlayback: true,
|
endlessPlayback: true,
|
||||||
enableConnect: false,
|
playerWakelock: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ part of '../database.dart';
|
|||||||
|
|
||||||
enum SourceType {
|
enum SourceType {
|
||||||
youtube._('YouTube'),
|
youtube._('YouTube'),
|
||||||
youtubeMusic._('YouTube Music');
|
youtubeMusic._('YouTube Music'),
|
||||||
|
netease._('Netease Music'),
|
||||||
|
kugou._('Kugou Music');
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
|||||||
26
lib/services/duration.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
extension DurationToHumanReadableString on Duration {
|
||||||
|
String toHumanReadableString({padZero = true}) {
|
||||||
|
final mm = inMinutes
|
||||||
|
.remainder(60)
|
||||||
|
.toString()
|
||||||
|
.padLeft(2, !padZero && inHours == 0 ? '' : '0');
|
||||||
|
final ss = inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
if (inHours > 0) {
|
||||||
|
final hh = inHours.toString().padLeft(2, !padZero ? '' : '0');
|
||||||
|
return '$hh:$mm:$ss';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '$mm:$ss';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ParseDuration on Duration {
|
||||||
|
static Duration fromString(String duration) {
|
||||||
|
final parts = duration.split(':').reversed.toList();
|
||||||
|
final seconds = int.parse(parts[0]);
|
||||||
|
final minutes = parts.length > 1 ? int.parse(parts[1]) : 0;
|
||||||
|
final hours = parts.length > 2 ? int.parse(parts[2]) : 0;
|
||||||
|
return Duration(hours: hours, minutes: minutes, seconds: seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:lrc/lrc.dart';
|
import 'package:lrc/lrc.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/providers/spotify.dart';
|
import 'package:rhythm_box/providers/spotify.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
import 'package:rhythm_box/services/lyrics/model.dart';
|
import 'package:rhythm_box/services/lyrics/model.dart';
|
||||||
@@ -164,9 +163,15 @@ class SyncedLyricsProvider extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return lyrics;
|
return lyrics;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stack) {
|
||||||
log('[Lyrics] Error: $e; Trace:\n$stackTrace');
|
Get.find<ErrorNotifier>().logError('[Lyrics] Error: $e', trace: stack);
|
||||||
rethrow;
|
return SubtitleSimple(
|
||||||
|
uri: Uri.parse('https://example.com/not-found'),
|
||||||
|
name: 'Lyrics Not Found',
|
||||||
|
lyrics: [],
|
||||||
|
rating: 0,
|
||||||
|
provider: 'Not Found',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:media_kit/media_kit.dart' hide Track;
|
import 'package:media_kit/media_kit.dart' hide Track;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:rhythm_box/platform.dart';
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/custom_player.dart';
|
import 'package:rhythm_box/services/audio_player/custom_player.dart';
|
||||||
import 'package:rhythm_box/services/local_track.dart';
|
import 'package:rhythm_box/services/local_track.dart';
|
||||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
@@ -85,7 +86,7 @@ abstract class AudioPlayerInterface {
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
_mkPlayer.stream.error.listen((event) {
|
_mkPlayer.stream.error.listen((event) {
|
||||||
log('[Playback] Error: $event');
|
Get.find<ErrorNotifier>().logError('[Playback][Media] Error: $event');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
|
||||||
class ActiveSourcedTrackProvider extends GetxController {
|
class ActiveSourcedTrackProvider extends GetxController {
|
||||||
Rx<SourcedTrack?> state = Rx(null);
|
Rx<SourcedTrack?> state = Rx(null);
|
||||||
@@ -17,23 +19,33 @@ class ActiveSourcedTrackProvider extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> swapSibling(SourceInfo sibling) async {
|
Future<void> swapSibling(SourceInfo sibling) async {
|
||||||
if (state.value == null) return;
|
final query = Get.find<QueryingTrackInfoProvider>();
|
||||||
await populateSibling();
|
query.isQueryingTrackInfo.value = true;
|
||||||
final newTrack = await state.value!.swapWithSibling(sibling);
|
|
||||||
if (newTrack == null) return;
|
|
||||||
|
|
||||||
state.value = newTrack;
|
try {
|
||||||
await audioPlayer.pause();
|
if (state.value == null) return;
|
||||||
|
await audioPlayer.pause();
|
||||||
|
await populateSibling();
|
||||||
|
final newTrack = await state.value!.swapWithSibling(sibling);
|
||||||
|
if (newTrack == null) return;
|
||||||
|
|
||||||
final playback = Get.find<AudioPlayerProvider>();
|
state.value = newTrack;
|
||||||
final oldActiveIndex = audioPlayer.currentIndex;
|
|
||||||
|
|
||||||
await playback.addTracksAtFirst([newTrack]);
|
final playback = Get.find<AudioPlayerProvider>();
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
final oldActiveIndex = audioPlayer.currentIndex;
|
||||||
await playback.jumpToTrack(newTrack);
|
|
||||||
|
|
||||||
await audioPlayer.removeTrack(oldActiveIndex);
|
await playback.addTracksAtFirst([newTrack]);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 30));
|
||||||
|
|
||||||
await audioPlayer.resume();
|
await audioPlayer.removeTrack(oldActiveIndex);
|
||||||
|
await playback.jumpToTrack(newTrack);
|
||||||
|
} catch (e, stack) {
|
||||||
|
Get.find<ErrorNotifier>().logError(
|
||||||
|
'[Playback] Failed to swap with siblings. Error: $e',
|
||||||
|
trace: stack);
|
||||||
|
} finally {
|
||||||
|
query.isQueryingTrackInfo.value = false;
|
||||||
|
await audioPlayer.resume();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import 'dart:developer';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart' hide Response;
|
import 'package:dio/dio.dart' hide Response;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get/get.dart' hide Response;
|
import 'package:get/get.dart' hide Response;
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
class ServerPlaybackRoutesProvider {
|
class ServerPlaybackRoutesProvider {
|
||||||
@@ -21,19 +23,37 @@ class ServerPlaybackRoutesProvider {
|
|||||||
|
|
||||||
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
|
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
|
||||||
final sourcedTrack = activeSourcedTrack.state.value?.id == track.id
|
final sourcedTrack = activeSourcedTrack.state.value?.id == track.id
|
||||||
? activeSourcedTrack
|
? activeSourcedTrack.state.value
|
||||||
: await Get.find<SourcedTrackProvider>().fetch(RhythmMedia(track));
|
: await Get.find<SourcedTrackProvider>().fetch(RhythmMedia(track));
|
||||||
|
|
||||||
activeSourcedTrack.updateTrack(sourcedTrack as SourcedTrack?);
|
activeSourcedTrack.updateTrack(sourcedTrack);
|
||||||
|
|
||||||
|
var url = sourcedTrack!.url;
|
||||||
|
|
||||||
|
if (sourcedTrack is NeteaseSourcedTrack) {
|
||||||
|
// Special processing for netease to get real assets url
|
||||||
|
final resp = await GetConnect(timeout: const Duration(seconds: 30)).get(
|
||||||
|
'${sourcedTrack.url}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
|
||||||
|
);
|
||||||
|
final realUrl = resp.body['data'][0]['url'];
|
||||||
|
url = realUrl;
|
||||||
|
} else if (sourcedTrack is KugouSourcedTrack) {
|
||||||
|
// Special processing for kugou to get real assets url
|
||||||
|
final resp = await GetConnect(timeout: const Duration(seconds: 30))
|
||||||
|
.get(sourcedTrack.url);
|
||||||
|
final realUrl =
|
||||||
|
KugouSourcedTrack.unescapeUrl(jsonDecode(resp.body)['url'][0]);
|
||||||
|
url = realUrl;
|
||||||
|
}
|
||||||
|
|
||||||
final res = await Dio().get(
|
final res = await Dio().get(
|
||||||
sourcedTrack!.url,
|
url,
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {
|
headers: {
|
||||||
...request.headers,
|
...request.headers,
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
'host': Uri.parse(sourcedTrack.url).host,
|
'host': Uri.parse(url).host,
|
||||||
'Cache-Control': 'max-age=0',
|
'Cache-Control': 'max-age=0',
|
||||||
'Connection': 'keep-alive',
|
'Connection': 'keep-alive',
|
||||||
},
|
},
|
||||||
@@ -58,8 +78,9 @@ class ServerPlaybackRoutesProvider {
|
|||||||
},
|
},
|
||||||
headers: res.headers.map,
|
headers: res.headers.map,
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stack) {
|
||||||
log('[PlaybackSever] Error: $e; Trace:\n $stackTrace');
|
Get.find<ErrorNotifier>()
|
||||||
|
.logError('[PlaybackSever] Error: $e', trace: stack);
|
||||||
return Response.internalServerError();
|
return Response.internalServerError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
lib/services/song_link/song_link.freezed.dart
Executable file → Normal file
@@ -30,8 +30,12 @@ mixin _$SongLink {
|
|||||||
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
|
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
|
||||||
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
|
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this SongLink to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@JsonKey(ignore: true)
|
|
||||||
|
/// Create a copy of SongLink
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
$SongLinkCopyWith<SongLink> get copyWith =>
|
$SongLinkCopyWith<SongLink> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
@@ -63,6 +67,8 @@ class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
|
|||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final $Res Function($Val) _then;
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SongLink
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
@@ -145,6 +151,8 @@ class __$$SongLinkImplCopyWithImpl<$Res>
|
|||||||
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
|
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
|
||||||
: super(_value, _then);
|
: super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of SongLink
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
@@ -261,12 +269,14 @@ class _$SongLinkImpl implements _SongLink {
|
|||||||
other.nativeAppUriDesktop == nativeAppUriDesktop));
|
other.nativeAppUriDesktop == nativeAppUriDesktop));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
|
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
|
||||||
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
|
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
/// Create a copy of SongLink
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||||
@@ -313,8 +323,11 @@ abstract class _SongLink implements SongLink {
|
|||||||
String? get nativeAppUriMobile;
|
String? get nativeAppUriMobile;
|
||||||
@override
|
@override
|
||||||
String? get nativeAppUriDesktop;
|
String? get nativeAppUriDesktop;
|
||||||
|
|
||||||
|
/// Create a copy of SongLink
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||||
import 'package:rhythm_box/services/utils.dart';
|
import 'package:rhythm_box/services/utils.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
@@ -55,6 +58,12 @@ abstract class SourcedTrack extends Track {
|
|||||||
.cast<SourceInfo>();
|
.cast<SourceInfo>();
|
||||||
|
|
||||||
return switch (audioSource) {
|
return switch (audioSource) {
|
||||||
|
AudioSource.netease => NeteaseSourcedTrack(
|
||||||
|
source: source,
|
||||||
|
siblings: siblings,
|
||||||
|
sourceInfo: sourceInfo,
|
||||||
|
track: track,
|
||||||
|
),
|
||||||
AudioSource.piped => PipedSourcedTrack(
|
AudioSource.piped => PipedSourcedTrack(
|
||||||
source: source,
|
source: source,
|
||||||
siblings: siblings,
|
siblings: siblings,
|
||||||
@@ -94,14 +103,27 @@ abstract class SourcedTrack extends Track {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return switch (audioSource) {
|
return switch (audioSource) {
|
||||||
|
AudioSource.netease =>
|
||||||
|
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||||
|
AudioSource.kugou =>
|
||||||
|
await KugouSourcedTrack.fetchFromTrack(track: track),
|
||||||
AudioSource.piped =>
|
AudioSource.piped =>
|
||||||
await PipedSourcedTrack.fetchFromTrack(track: track),
|
await PipedSourcedTrack.fetchFromTrack(track: track),
|
||||||
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||||
};
|
};
|
||||||
} on TrackNotFoundError catch (_) {
|
} on TrackNotFoundError catch (err) {
|
||||||
// TODO Try to look it up in other source
|
Get.find<ErrorNotifier>().showError(
|
||||||
// But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary
|
'${err.toString()} via ${preferences.audioSource.label}, querying in fallback sources...',
|
||||||
rethrow;
|
);
|
||||||
|
return switch (preferences.audioSource) {
|
||||||
|
AudioSource.piped ||
|
||||||
|
AudioSource.youtube =>
|
||||||
|
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||||
|
AudioSource.netease =>
|
||||||
|
await KugouSourcedTrack.fetchFromTrack(track: track),
|
||||||
|
AudioSource.kugou =>
|
||||||
|
await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||||
|
};
|
||||||
} on HttpClientClosedException catch (_) {
|
} on HttpClientClosedException catch (_) {
|
||||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||||
} on VideoUnplayableException catch (_) {
|
} on VideoUnplayableException catch (_) {
|
||||||
|
|||||||
229
lib/services/sourced_track/sources/kugou.dart
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:get/get.dart' hide Value;
|
||||||
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
|
|
||||||
|
class KugouSourceInfo extends SourceInfo {
|
||||||
|
KugouSourceInfo({
|
||||||
|
required super.id,
|
||||||
|
required super.title,
|
||||||
|
required super.artist,
|
||||||
|
required super.thumbnail,
|
||||||
|
required super.pageUrl,
|
||||||
|
required super.duration,
|
||||||
|
required super.artistUrl,
|
||||||
|
required super.album,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class KugouSourcedTrack extends SourcedTrack {
|
||||||
|
KugouSourcedTrack({
|
||||||
|
required super.source,
|
||||||
|
required super.siblings,
|
||||||
|
required super.sourceInfo,
|
||||||
|
required super.track,
|
||||||
|
});
|
||||||
|
|
||||||
|
static String unescapeUrl(String src) {
|
||||||
|
return src.replaceAll('\\/', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String getBaseUrl() {
|
||||||
|
return 'http://mobilecdn.kugou.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
static GetConnect getClient() {
|
||||||
|
final client = GetConnect(
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
client.baseUrl = getBaseUrl();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<KugouSourcedTrack> fetchFromTrack({
|
||||||
|
required Track track,
|
||||||
|
}) async {
|
||||||
|
final DatabaseProvider db = Get.find();
|
||||||
|
final cachedSource = await (db.database.select(db.database.sourceMatchTable)
|
||||||
|
..where((s) => s.trackId.equals(track.id!))
|
||||||
|
..limit(1)
|
||||||
|
..orderBy([
|
||||||
|
(s) =>
|
||||||
|
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
|
||||||
|
]))
|
||||||
|
.get()
|
||||||
|
.then((s) => s.firstOrNull);
|
||||||
|
|
||||||
|
if (cachedSource == null || cachedSource.sourceType != SourceType.kugou) {
|
||||||
|
final siblings = await fetchSiblings(track: track);
|
||||||
|
if (siblings.isEmpty) {
|
||||||
|
throw TrackNotFoundError(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.database.into(db.database.sourceMatchTable).insert(
|
||||||
|
SourceMatchTableCompanion.insert(
|
||||||
|
trackId: track.id!,
|
||||||
|
sourceId: siblings.first.info.id,
|
||||||
|
sourceType: const Value(SourceType.kugou),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return KugouSourcedTrack(
|
||||||
|
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||||
|
source: siblings.first.source as SourceMap,
|
||||||
|
sourceInfo: siblings.first.info,
|
||||||
|
track: track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return KugouSourcedTrack(
|
||||||
|
siblings: [],
|
||||||
|
source: toSourceMap(cachedSource),
|
||||||
|
sourceInfo: KugouSourceInfo(
|
||||||
|
id: cachedSource.sourceId,
|
||||||
|
artist: 'unknown',
|
||||||
|
artistUrl: '#',
|
||||||
|
pageUrl: '#',
|
||||||
|
thumbnail: '#',
|
||||||
|
title: 'unknown',
|
||||||
|
duration: Duration.zero,
|
||||||
|
album: 'unknown',
|
||||||
|
),
|
||||||
|
track: track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SourceMap toSourceMap(dynamic manifest) {
|
||||||
|
const baseUrl = 'http://trackercdn.kugou.com/i/v2';
|
||||||
|
|
||||||
|
final hash = manifest is SourceMatchTableData
|
||||||
|
? manifest.sourceId
|
||||||
|
: manifest?['hash'];
|
||||||
|
final key = md5.convert(utf8.encode('${hash}kgcloudv2')).toString();
|
||||||
|
final url =
|
||||||
|
'$baseUrl/song/url?key=$key&hash=$hash&appid=1005&pid=2&cmd=25&behavior=play';
|
||||||
|
|
||||||
|
return SourceMap(
|
||||||
|
m4a: SourceQualityMap(
|
||||||
|
high: url,
|
||||||
|
medium: url,
|
||||||
|
low: url,
|
||||||
|
),
|
||||||
|
weba: SourceQualityMap(
|
||||||
|
high: url,
|
||||||
|
medium: url,
|
||||||
|
low: url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<SiblingType>> fetchSiblings({
|
||||||
|
required Track track,
|
||||||
|
}) async {
|
||||||
|
final query = SourcedTrack.getSearchTerm(track);
|
||||||
|
|
||||||
|
final client = getClient();
|
||||||
|
final resp = await client.get(
|
||||||
|
'/api/v3/search/song?keyword=${Uri.encodeComponent(query)}&page=1&pagesize=10',
|
||||||
|
);
|
||||||
|
final results = jsonDecode(resp.body)['data']['info'];
|
||||||
|
|
||||||
|
// We can just trust kugou music for now
|
||||||
|
// If we need to check is the result correct, refer to this code
|
||||||
|
// https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129
|
||||||
|
final matchedResults = results.map(toSiblingType).toList();
|
||||||
|
|
||||||
|
return matchedResults.cast<SiblingType>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<KugouSourcedTrack> copyWithSibling() async {
|
||||||
|
if (siblings.isNotEmpty) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
final fetchedSiblings = await fetchSiblings(track: this);
|
||||||
|
|
||||||
|
return KugouSourcedTrack(
|
||||||
|
siblings: fetchedSiblings
|
||||||
|
.where((s) => s.info.id != sourceInfo.id)
|
||||||
|
.map((s) => s.info)
|
||||||
|
.toList(),
|
||||||
|
source: source,
|
||||||
|
sourceInfo: sourceInfo,
|
||||||
|
track: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<KugouSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||||
|
if (sibling.id == sourceInfo.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a sibling source that was fetched from the search results
|
||||||
|
final isStepSibling = siblings.none((s) => s.id == sibling.id);
|
||||||
|
|
||||||
|
final newSourceInfo = isStepSibling
|
||||||
|
? sibling
|
||||||
|
: siblings.firstWhere((s) => s.id == sibling.id);
|
||||||
|
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||||
|
..insert(0, sourceInfo);
|
||||||
|
|
||||||
|
final info = newSourceInfo as KugouSourceInfo;
|
||||||
|
final source = toSourceMap(newSourceInfo.id);
|
||||||
|
|
||||||
|
final db = Get.find<DatabaseProvider>();
|
||||||
|
await db.database.into(db.database.sourceMatchTable).insert(
|
||||||
|
SourceMatchTableCompanion.insert(
|
||||||
|
trackId: id!,
|
||||||
|
sourceId: info.id,
|
||||||
|
sourceType: const Value(SourceType.kugou),
|
||||||
|
// Because we're sorting by createdAt in the query
|
||||||
|
// we have to update it to indicate priority
|
||||||
|
createdAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
mode: InsertMode.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
return KugouSourcedTrack(
|
||||||
|
siblings: newSiblings,
|
||||||
|
source: source,
|
||||||
|
sourceInfo: info,
|
||||||
|
track: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static KugouSourceInfo toSourceInfo(dynamic item) {
|
||||||
|
return KugouSourceInfo(
|
||||||
|
id: item['hash'],
|
||||||
|
artist: item['singername'],
|
||||||
|
artistUrl: '#',
|
||||||
|
pageUrl: '#',
|
||||||
|
thumbnail: unescapeUrl(item['trans_param']['union_cover'])
|
||||||
|
.replaceFirst('/{size}', ''),
|
||||||
|
title: item['songname'],
|
||||||
|
duration: Duration(seconds: item['duration']),
|
||||||
|
album: item['album_name'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SiblingType toSiblingType(dynamic item) {
|
||||||
|
final SiblingType sibling = (
|
||||||
|
info: toSourceInfo(item),
|
||||||
|
source: toSourceMap(item),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/services/sourced_track/sources/netease.dart
Executable file
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:get/get.dart' hide Value;
|
||||||
|
import 'package:get/get_connect/http/src/request/request.dart';
|
||||||
|
import 'package:rhythm_box/providers/auth.dart';
|
||||||
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
|
|
||||||
|
class NeteaseSourceInfo extends SourceInfo {
|
||||||
|
NeteaseSourceInfo({
|
||||||
|
required super.id,
|
||||||
|
required super.title,
|
||||||
|
required super.artist,
|
||||||
|
required super.thumbnail,
|
||||||
|
required super.pageUrl,
|
||||||
|
required super.duration,
|
||||||
|
required super.artistUrl,
|
||||||
|
required super.album,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NeteaseSourcedTrack extends SourcedTrack {
|
||||||
|
NeteaseSourcedTrack({
|
||||||
|
required super.source,
|
||||||
|
required super.siblings,
|
||||||
|
required super.sourceInfo,
|
||||||
|
required super.track,
|
||||||
|
});
|
||||||
|
|
||||||
|
static String getBaseUrl() {
|
||||||
|
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||||
|
return preferences.neteaseApiInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GetConnect getClient() {
|
||||||
|
final client = GetConnect(
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
client.baseUrl = getBaseUrl();
|
||||||
|
client.httpClient.addRequestModifier((Request request) async {
|
||||||
|
final AuthenticationProvider auth = Get.find();
|
||||||
|
if (auth.auth.value?.neteaseCookie != null) {
|
||||||
|
final cookie =
|
||||||
|
'MUSIC_U=${auth.auth.value!.getNeteaseCookie('MUSIC_U')}';
|
||||||
|
if (request.headers['Cookie'] == null) {
|
||||||
|
request.headers['Cookie'] = cookie;
|
||||||
|
} else {
|
||||||
|
request.headers['Cookie'] = request.headers['Cookie']! + cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _lookedUpRealIp;
|
||||||
|
|
||||||
|
static Future<String> lookupRealIp() async {
|
||||||
|
if (_lookedUpRealIp != null) return _lookedUpRealIp!;
|
||||||
|
const ipCheckUrl = 'https://api.ipify.org';
|
||||||
|
final client = GetConnect(timeout: const Duration(seconds: 30));
|
||||||
|
final resp = await client.get(ipCheckUrl);
|
||||||
|
_lookedUpRealIp = resp.body;
|
||||||
|
return _lookedUpRealIp!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<NeteaseSourcedTrack> fetchFromTrack({
|
||||||
|
required Track track,
|
||||||
|
}) async {
|
||||||
|
final DatabaseProvider db = Get.find();
|
||||||
|
final cachedSource = await (db.database.select(db.database.sourceMatchTable)
|
||||||
|
..where((s) => s.trackId.equals(track.id!))
|
||||||
|
..limit(1)
|
||||||
|
..orderBy([
|
||||||
|
(s) =>
|
||||||
|
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
|
||||||
|
]))
|
||||||
|
.get()
|
||||||
|
.then((s) => s.firstOrNull);
|
||||||
|
|
||||||
|
if (cachedSource == null || cachedSource.sourceType != SourceType.netease) {
|
||||||
|
final siblings = await fetchSiblings(track: track);
|
||||||
|
if (siblings.isEmpty) {
|
||||||
|
throw TrackNotFoundError(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = getClient();
|
||||||
|
final checkResp = await client.get(
|
||||||
|
'/check/music?id=${siblings.first.info.id}&realIP=${await lookupRealIp()}',
|
||||||
|
);
|
||||||
|
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
|
||||||
|
|
||||||
|
await db.database.into(db.database.sourceMatchTable).insert(
|
||||||
|
SourceMatchTableCompanion.insert(
|
||||||
|
trackId: track.id!,
|
||||||
|
sourceId: siblings.first.info.id,
|
||||||
|
sourceType: const Value(SourceType.netease),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NeteaseSourcedTrack(
|
||||||
|
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||||
|
source: siblings.first.source as SourceMap,
|
||||||
|
sourceInfo: siblings.first.info,
|
||||||
|
track: track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = getClient();
|
||||||
|
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
|
||||||
|
final item = (resp.body['songs'] as List<dynamic>).firstOrNull;
|
||||||
|
|
||||||
|
if (item == null) throw TrackNotFoundError(track);
|
||||||
|
|
||||||
|
final checkResp = await client.get(
|
||||||
|
'/check/music?id=${item['id']}&realIP=${await lookupRealIp()}',
|
||||||
|
);
|
||||||
|
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
|
||||||
|
|
||||||
|
return NeteaseSourcedTrack(
|
||||||
|
siblings: [],
|
||||||
|
source: toSourceMap(item),
|
||||||
|
sourceInfo: NeteaseSourceInfo(
|
||||||
|
id: item['id'].toString(),
|
||||||
|
artist: item['ar'].map((x) => x['name']).join(','),
|
||||||
|
artistUrl: 'https://music.163.com/#/artist?id=${item['ar'][0]['id']}',
|
||||||
|
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||||
|
thumbnail: item['al']['picUrl'],
|
||||||
|
title: item['name'],
|
||||||
|
duration: Duration(milliseconds: item['dt']),
|
||||||
|
album: item['al']['name'],
|
||||||
|
),
|
||||||
|
track: track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SourceMap toSourceMap(dynamic manifest) {
|
||||||
|
final baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Due to netease may provide m4a, mp3 and others, we cannot decide this so mock this data.
|
||||||
|
return SourceMap(
|
||||||
|
m4a: SourceQualityMap(
|
||||||
|
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||||
|
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||||
|
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||||
|
),
|
||||||
|
weba: SourceQualityMap(
|
||||||
|
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||||
|
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||||
|
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<SiblingType>> fetchSiblings({
|
||||||
|
required Track track,
|
||||||
|
}) async {
|
||||||
|
final query = SourcedTrack.getSearchTerm(track);
|
||||||
|
|
||||||
|
final client = getClient();
|
||||||
|
final resp = await client.get(
|
||||||
|
'/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}',
|
||||||
|
);
|
||||||
|
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
|
||||||
|
final results = resp.body['result']['songs'];
|
||||||
|
|
||||||
|
// We can just trust netease music for now
|
||||||
|
// If we need to check is the result correct, refer to this code
|
||||||
|
// https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129
|
||||||
|
final matchedResults = results.map(toSiblingType).toList();
|
||||||
|
|
||||||
|
return matchedResults.cast<SiblingType>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NeteaseSourcedTrack> copyWithSibling() async {
|
||||||
|
if (siblings.isNotEmpty) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
final fetchedSiblings = await fetchSiblings(track: this);
|
||||||
|
|
||||||
|
return NeteaseSourcedTrack(
|
||||||
|
siblings: fetchedSiblings
|
||||||
|
.where((s) => s.info.id != sourceInfo.id)
|
||||||
|
.map((s) => s.info)
|
||||||
|
.toList(),
|
||||||
|
source: source,
|
||||||
|
sourceInfo: sourceInfo,
|
||||||
|
track: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NeteaseSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||||
|
if (sibling.id == sourceInfo.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a sibling source that was fetched from the search results
|
||||||
|
final isStepSibling = siblings.none((s) => s.id == sibling.id);
|
||||||
|
|
||||||
|
final newSourceInfo = isStepSibling
|
||||||
|
? sibling
|
||||||
|
: siblings.firstWhere((s) => s.id == sibling.id);
|
||||||
|
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||||
|
..insert(0, sourceInfo);
|
||||||
|
|
||||||
|
final client = getClient();
|
||||||
|
final resp = await client.get('/song/detail?ids=${newSourceInfo.id}');
|
||||||
|
final item = (resp.body['songs'] as List<dynamic>).first;
|
||||||
|
|
||||||
|
final (:info, :source) = toSiblingType(item);
|
||||||
|
|
||||||
|
final db = Get.find<DatabaseProvider>();
|
||||||
|
await db.database.into(db.database.sourceMatchTable).insert(
|
||||||
|
SourceMatchTableCompanion.insert(
|
||||||
|
trackId: id!,
|
||||||
|
sourceId: info.id,
|
||||||
|
sourceType: const Value(SourceType.netease),
|
||||||
|
// Because we're sorting by createdAt in the query
|
||||||
|
// we have to update it to indicate priority
|
||||||
|
createdAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
mode: InsertMode.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NeteaseSourcedTrack(
|
||||||
|
siblings: newSiblings,
|
||||||
|
source: source!,
|
||||||
|
sourceInfo: info,
|
||||||
|
track: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NeteaseSourceInfo toSourceInfo(dynamic item) {
|
||||||
|
final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0];
|
||||||
|
|
||||||
|
return NeteaseSourceInfo(
|
||||||
|
id: item['id'].toString(),
|
||||||
|
artist: item['ar'] != null
|
||||||
|
? item['ar'].map((x) => x['name']).join(',')
|
||||||
|
: item['artists'].map((x) => x['name']).toString(),
|
||||||
|
artistUrl: 'https://music.163.com/#/artist?id=${firstArtist['id']}',
|
||||||
|
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||||
|
thumbnail: item['al']?['picUrl'] ??
|
||||||
|
'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg',
|
||||||
|
title: item['name'],
|
||||||
|
duration: item['dt'] != null
|
||||||
|
? Duration(milliseconds: item['dt'])
|
||||||
|
: Duration(milliseconds: item['duration']),
|
||||||
|
album: item['al']?['name'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SiblingType toSiblingType(dynamic item) {
|
||||||
|
final SiblingType sibling = (
|
||||||
|
info: toSourceInfo(item),
|
||||||
|
source: toSourceMap(item),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:get/get.dart' hide Value;
|
import 'package:get/get.dart' hide Value;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:rhythm_box/providers/database.dart';
|
import 'package:rhythm_box/providers/database.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
import 'package:rhythm_box/services/database/database.dart';
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
import 'package:rhythm_box/services/utils.dart';
|
import 'package:rhythm_box/services/utils.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
@@ -70,6 +69,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
|||||||
sourceId: siblings.first.info.id,
|
sourceId: siblings.first.info.id,
|
||||||
sourceType: const Value(SourceType.youtube),
|
sourceType: const Value(SourceType.youtube),
|
||||||
),
|
),
|
||||||
|
mode: InsertMode.insertOrReplace,
|
||||||
);
|
);
|
||||||
|
|
||||||
return YoutubeSourcedTrack(
|
return YoutubeSourcedTrack(
|
||||||
@@ -86,7 +86,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
|||||||
cachedSource.sourceId,
|
cachedSource.sourceId,
|
||||||
)
|
)
|
||||||
.timeout(
|
.timeout(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 30),
|
||||||
onTimeout: () => throw ClientException('Timeout'),
|
onTimeout: () => throw ClientException('Timeout'),
|
||||||
);
|
);
|
||||||
return YoutubeSourcedTrack(
|
return YoutubeSourcedTrack(
|
||||||
@@ -141,7 +141,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
|||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
final manifest =
|
final manifest =
|
||||||
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 30),
|
||||||
onTimeout: () => throw ClientException('Timeout'),
|
onTimeout: () => throw ClientException('Timeout'),
|
||||||
);
|
);
|
||||||
sourceMap = toSourceMap(manifest);
|
sourceMap = toSourceMap(manifest);
|
||||||
@@ -242,14 +242,15 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
|||||||
];
|
];
|
||||||
} on VideoUnplayableException catch (e) {
|
} on VideoUnplayableException catch (e) {
|
||||||
// Ignore this error and continue with the search
|
// Ignore this error and continue with the search
|
||||||
log('[Source][YoutubeMusic] Unable to search data: $e');
|
Get.find<ErrorNotifier>().logError(
|
||||||
|
'[Source][YoutubeMusic] Unable to play stream on youtube: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final query = SourcedTrack.getSearchTerm(track);
|
final query = SourcedTrack.getSearchTerm(track);
|
||||||
|
|
||||||
final searchResults = await youtubeClient.search.search(
|
final searchResults = await youtubeClient.search.search(
|
||||||
'$query - Topic',
|
query,
|
||||||
filter: TypeFilters.video,
|
filter: TypeFilters.video,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
|||||||
final manifest = await youtubeClient.videos.streamsClient
|
final manifest = await youtubeClient.videos.streamsClient
|
||||||
.getManifest(newSourceInfo.id)
|
.getManifest(newSourceInfo.id)
|
||||||
.timeout(
|
.timeout(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 30),
|
||||||
onTimeout: () => throw ClientException('Timeout'),
|
onTimeout: () => throw ClientException('Timeout'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
214
lib/services/spotify/spotify_endpoints.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:rhythm_box/services/spotify/spotify_feed.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
class CustomSpotifyEndpoints {
|
||||||
|
static const _baseUrl = 'https://api.spotify.com/v1';
|
||||||
|
final String accessToken;
|
||||||
|
final Dio _client;
|
||||||
|
|
||||||
|
CustomSpotifyEndpoints(this.accessToken)
|
||||||
|
: _client = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: _baseUrl,
|
||||||
|
responseType: ResponseType.json,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
if (accessToken.isNotEmpty)
|
||||||
|
'authorization': 'Bearer $accessToken',
|
||||||
|
'accept': 'application/json',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// views API
|
||||||
|
|
||||||
|
/// Get a single view of given genre
|
||||||
|
///
|
||||||
|
/// Currently known genres are:
|
||||||
|
/// - new-releases-page
|
||||||
|
/// - made-for-x-hub (it requires authentication)
|
||||||
|
/// - my-mix-genres (it requires authentication)
|
||||||
|
/// - artist-seed-mixes (it requires authentication)
|
||||||
|
/// - my-mix-decades (it requires authentication)
|
||||||
|
/// - my-mix-moods (it requires authentication)
|
||||||
|
/// - podcasts-and-more (it requires authentication)
|
||||||
|
/// - uniquely-yours-in-hub (it requires authentication)
|
||||||
|
/// - made-for-x-dailymix (it requires authentication)
|
||||||
|
/// - made-for-x-discovery (it requires authentication)
|
||||||
|
Future<Map<String, dynamic>> getView(
|
||||||
|
String view, {
|
||||||
|
int limit = 20,
|
||||||
|
int contentLimit = 10,
|
||||||
|
List<String> types = const [
|
||||||
|
'album',
|
||||||
|
'playlist',
|
||||||
|
'artist',
|
||||||
|
'show',
|
||||||
|
'station',
|
||||||
|
'episode',
|
||||||
|
'merch',
|
||||||
|
'artist_concerts',
|
||||||
|
'uri_link'
|
||||||
|
],
|
||||||
|
String imageStyle = 'gradient_overlay',
|
||||||
|
String includeExternal = 'audio',
|
||||||
|
String? locale,
|
||||||
|
Market? market,
|
||||||
|
Market? country,
|
||||||
|
}) async {
|
||||||
|
if (accessToken.isEmpty) {
|
||||||
|
throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParams = {
|
||||||
|
'limit': limit.toString(),
|
||||||
|
'content_limit': contentLimit.toString(),
|
||||||
|
'types': types.join(','),
|
||||||
|
'image_style': imageStyle,
|
||||||
|
'include_external': includeExternal,
|
||||||
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
if (locale != null) 'locale': locale,
|
||||||
|
if (market != null) 'market': market.name,
|
||||||
|
if (country != null) 'country': country.name,
|
||||||
|
}.entries.map((e) => '${e.key}=${e.value}').join('&');
|
||||||
|
|
||||||
|
final res = await _client.getUri(
|
||||||
|
Uri.parse('$_baseUrl/views/$view?$queryParams'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
throw Exception(
|
||||||
|
'[CustomSpotifyEndpoints.getView]: Failed to get view'
|
||||||
|
'\nStatus code: ${res.statusCode}'
|
||||||
|
'\nBody: ${res.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listGenreSeeds() async {
|
||||||
|
final res = await _client.getUri(
|
||||||
|
Uri.parse('$_baseUrl/recommendations/available-genre-seeds'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
final body = res.data;
|
||||||
|
return List<String>.from(body['genres'] ?? []);
|
||||||
|
} else {
|
||||||
|
throw Exception(
|
||||||
|
'[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds'
|
||||||
|
'\nStatus code: ${res.statusCode}'
|
||||||
|
'\nBody: ${res.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SpotifyHomeFeed> getHomeFeed({
|
||||||
|
required String spTCookie,
|
||||||
|
required Market country,
|
||||||
|
}) async {
|
||||||
|
final headers = {
|
||||||
|
'app-platform': 'WebPlayer',
|
||||||
|
'authorization': 'Bearer $accessToken',
|
||||||
|
'content-type': 'application/json;charset=UTF-8',
|
||||||
|
'dnt': '1',
|
||||||
|
'origin': 'https://open.spotify.com',
|
||||||
|
'referer': 'https://open.spotify.com/'
|
||||||
|
};
|
||||||
|
final response = await _client.getUri(
|
||||||
|
Uri(
|
||||||
|
scheme: 'https',
|
||||||
|
host: 'api-partner.spotify.com',
|
||||||
|
path: '/pathfinder/v1/query',
|
||||||
|
queryParameters: {
|
||||||
|
'operationName': 'home',
|
||||||
|
'variables': jsonEncode({
|
||||||
|
'timeZone': tz.local.name,
|
||||||
|
'sp_t': spTCookie,
|
||||||
|
'country': country.name,
|
||||||
|
'facet': null,
|
||||||
|
'sectionItemsLimit': 10
|
||||||
|
}),
|
||||||
|
'extensions': jsonEncode(
|
||||||
|
{
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
|
||||||
|
/// GraphQL persisted Query hash
|
||||||
|
/// This can change overtime. We've to lookout for it
|
||||||
|
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
|
||||||
|
'sha256Hash':
|
||||||
|
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = SpotifyHomeFeed.fromJson(
|
||||||
|
transformHomeFeedJsonMap(response.data),
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SpotifyHomeFeedSection> getHomeFeedSection(
|
||||||
|
String sectionUri, {
|
||||||
|
required String spTCookie,
|
||||||
|
required Market country,
|
||||||
|
}) async {
|
||||||
|
final headers = {
|
||||||
|
'app-platform': 'WebPlayer',
|
||||||
|
'authorization': 'Bearer $accessToken',
|
||||||
|
'content-type': 'application/json;charset=UTF-8',
|
||||||
|
'dnt': '1',
|
||||||
|
'origin': 'https://open.spotify.com',
|
||||||
|
'referer': 'https://open.spotify.com/'
|
||||||
|
};
|
||||||
|
final response = await _client.getUri(
|
||||||
|
Uri(
|
||||||
|
scheme: 'https',
|
||||||
|
host: 'api-partner.spotify.com',
|
||||||
|
path: '/pathfinder/v1/query',
|
||||||
|
queryParameters: {
|
||||||
|
'operationName': 'homeSection',
|
||||||
|
'variables': jsonEncode({
|
||||||
|
'timeZone': tz.local.name,
|
||||||
|
'sp_t': spTCookie,
|
||||||
|
'country': country.name,
|
||||||
|
'uri': sectionUri
|
||||||
|
}),
|
||||||
|
'extensions': jsonEncode(
|
||||||
|
{
|
||||||
|
'persistedQuery': {
|
||||||
|
'version': 1,
|
||||||
|
|
||||||
|
/// GraphQL persisted Query hash
|
||||||
|
/// This can change overtime. We've to lookout for it
|
||||||
|
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
|
||||||
|
'sha256Hash':
|
||||||
|
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = SpotifyHomeFeedSection.fromJson(
|
||||||
|
transformSectionItemJsonMap(
|
||||||
|
response.data['data']['homeSections']['sections'][0],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
lib/services/spotify/spotify_feed.dart
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
part 'spotify_feed.freezed.dart';
|
||||||
|
part 'spotify_feed.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifySectionPlaylist with _$SpotifySectionPlaylist {
|
||||||
|
const SpotifySectionPlaylist._();
|
||||||
|
|
||||||
|
const factory SpotifySectionPlaylist({
|
||||||
|
required String description,
|
||||||
|
required String format,
|
||||||
|
required List<SpotifySectionItemImage> images,
|
||||||
|
required String name,
|
||||||
|
required String owner,
|
||||||
|
required String uri,
|
||||||
|
}) = _SpotifySectionPlaylist;
|
||||||
|
|
||||||
|
factory SpotifySectionPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionPlaylistFromJson(json);
|
||||||
|
|
||||||
|
String get id => uri.split(':').last;
|
||||||
|
|
||||||
|
Playlist get asPlaylist {
|
||||||
|
return Playlist()
|
||||||
|
..id = id
|
||||||
|
..name = name
|
||||||
|
..description = description
|
||||||
|
..collaborative = false
|
||||||
|
..images = images.map((e) => e.asImage).toList()
|
||||||
|
..owner = (User()..displayName = 'Spotify')
|
||||||
|
..uri = uri
|
||||||
|
..type = 'playlist';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifySectionArtist with _$SpotifySectionArtist {
|
||||||
|
const SpotifySectionArtist._();
|
||||||
|
|
||||||
|
const factory SpotifySectionArtist({
|
||||||
|
required String name,
|
||||||
|
required String uri,
|
||||||
|
required List<SpotifySectionItemImage> images,
|
||||||
|
}) = _SpotifySectionArtist;
|
||||||
|
|
||||||
|
factory SpotifySectionArtist.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionArtistFromJson(json);
|
||||||
|
|
||||||
|
String get id => uri.split(':').last;
|
||||||
|
|
||||||
|
Artist get asArtist {
|
||||||
|
return Artist()
|
||||||
|
..id = id
|
||||||
|
..name = name
|
||||||
|
..images = images.map((e) => e.asImage).toList()
|
||||||
|
..type = 'artist'
|
||||||
|
..uri = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifySectionAlbum with _$SpotifySectionAlbum {
|
||||||
|
const SpotifySectionAlbum._();
|
||||||
|
|
||||||
|
const factory SpotifySectionAlbum({
|
||||||
|
required List<SpotifySectionAlbumArtist> artists,
|
||||||
|
required List<SpotifySectionItemImage> images,
|
||||||
|
required String name,
|
||||||
|
required String uri,
|
||||||
|
}) = _SpotifySectionAlbum;
|
||||||
|
|
||||||
|
factory SpotifySectionAlbum.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionAlbumFromJson(json);
|
||||||
|
|
||||||
|
String get id => uri.split(':').last;
|
||||||
|
|
||||||
|
Album get asAlbum {
|
||||||
|
return Album()
|
||||||
|
..id = id
|
||||||
|
..name = name
|
||||||
|
..artists = artists.map((a) => a.asArtist).toList()
|
||||||
|
..albumType = AlbumType.album
|
||||||
|
..images = images.map((e) => e.asImage).toList()
|
||||||
|
..uri = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist {
|
||||||
|
const SpotifySectionAlbumArtist._();
|
||||||
|
|
||||||
|
const factory SpotifySectionAlbumArtist({
|
||||||
|
required String name,
|
||||||
|
required String uri,
|
||||||
|
}) = _SpotifySectionAlbumArtist;
|
||||||
|
|
||||||
|
factory SpotifySectionAlbumArtist.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionAlbumArtistFromJson(json);
|
||||||
|
|
||||||
|
String get id => uri.split(':').last;
|
||||||
|
|
||||||
|
Artist get asArtist {
|
||||||
|
return Artist()
|
||||||
|
..id = id
|
||||||
|
..name = name
|
||||||
|
..type = 'artist'
|
||||||
|
..uri = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifySectionItemImage with _$SpotifySectionItemImage {
|
||||||
|
const SpotifySectionItemImage._();
|
||||||
|
|
||||||
|
const factory SpotifySectionItemImage({
|
||||||
|
required num? height,
|
||||||
|
required String url,
|
||||||
|
required num? width,
|
||||||
|
}) = _SpotifySectionItemImage;
|
||||||
|
|
||||||
|
factory SpotifySectionItemImage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionItemImageFromJson(json);
|
||||||
|
|
||||||
|
Image get asImage {
|
||||||
|
return Image()
|
||||||
|
..height = height?.toInt()
|
||||||
|
..width = width?.toInt()
|
||||||
|
..url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem {
|
||||||
|
factory SpotifyHomeFeedSectionItem({
|
||||||
|
required String typename,
|
||||||
|
SpotifySectionPlaylist? playlist,
|
||||||
|
SpotifySectionArtist? artist,
|
||||||
|
SpotifySectionAlbum? album,
|
||||||
|
}) = _SpotifyHomeFeedSectionItem;
|
||||||
|
|
||||||
|
factory SpotifyHomeFeedSectionItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedSectionItemFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection {
|
||||||
|
factory SpotifyHomeFeedSection({
|
||||||
|
required String typename,
|
||||||
|
String? title,
|
||||||
|
required String uri,
|
||||||
|
required List<SpotifyHomeFeedSectionItem> items,
|
||||||
|
}) = _SpotifyHomeFeedSection;
|
||||||
|
|
||||||
|
factory SpotifyHomeFeedSection.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedSectionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SpotifyHomeFeed with _$SpotifyHomeFeed {
|
||||||
|
factory SpotifyHomeFeed({
|
||||||
|
required String greeting,
|
||||||
|
required List<SpotifyHomeFeedSection> sections,
|
||||||
|
}) = _SpotifyHomeFeed;
|
||||||
|
|
||||||
|
factory SpotifyHomeFeed.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> transformSectionItemTypeJsonMap(
|
||||||
|
Map<String, dynamic> json) {
|
||||||
|
final data = json['content']['data'];
|
||||||
|
final objType = json['content']['data']['__typename'];
|
||||||
|
return {
|
||||||
|
'typename': json['content']['__typename'],
|
||||||
|
if (objType == 'Playlist')
|
||||||
|
'playlist': {
|
||||||
|
'name': data['name'],
|
||||||
|
'description': data['description'],
|
||||||
|
'format': data['format'],
|
||||||
|
'images': (data['images']['items'] as List)
|
||||||
|
.expand((j) => j['sources'] as dynamic)
|
||||||
|
.toList()
|
||||||
|
.cast<Map<String, dynamic>>(),
|
||||||
|
'owner': data['ownerV2']['data']['name'],
|
||||||
|
'uri': data['uri']
|
||||||
|
},
|
||||||
|
if (objType == 'Artist')
|
||||||
|
'artist': {
|
||||||
|
'name': data['profile']['name'],
|
||||||
|
'uri': data['uri'],
|
||||||
|
'images': data['visuals']['avatarImage']['sources'],
|
||||||
|
},
|
||||||
|
if (objType == 'Album')
|
||||||
|
'album': {
|
||||||
|
'name': data['name'],
|
||||||
|
'uri': data['uri'],
|
||||||
|
'images': data['coverArt']['sources'],
|
||||||
|
'artists': data['artists']['items']
|
||||||
|
.map(
|
||||||
|
(artist) => {
|
||||||
|
'name': artist['profile']['name'],
|
||||||
|
'uri': artist['uri'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> transformSectionItemJsonMap(Map<String, dynamic> json) {
|
||||||
|
return {
|
||||||
|
'typename': json['data']['__typename'],
|
||||||
|
'title': json['data']?['title']?['text'],
|
||||||
|
'uri': json['uri'],
|
||||||
|
'items': (json['sectionItems']['items'] as List)
|
||||||
|
.map(
|
||||||
|
(data) =>
|
||||||
|
transformSectionItemTypeJsonMap(data as Map<String, dynamic>)
|
||||||
|
as dynamic,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(w) =>
|
||||||
|
w['playlist'] != null ||
|
||||||
|
w['artist'] != null ||
|
||||||
|
w['album'] != null,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> transformHomeFeedJsonMap(Map<String, dynamic> json) {
|
||||||
|
return {
|
||||||
|
'greeting': json['data']['home']['greeting']['text'],
|
||||||
|
'sections':
|
||||||
|
(json['data']['home']['sectionContainer']['sections']['items'] as List)
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
transformSectionItemJsonMap(item as Map<String, dynamic>)
|
||||||
|
as dynamic,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
};
|
||||||
|
}
|
||||||
1776
lib/services/spotify/spotify_feed.freezed.dart
Normal file
169
lib/services/spotify/spotify_feed.g.dart
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'spotify_feed.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionPlaylistImpl(
|
||||||
|
description: json['description'] as String,
|
||||||
|
format: json['format'] as String,
|
||||||
|
images: (json['images'] as List<dynamic>)
|
||||||
|
.map((e) =>
|
||||||
|
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
name: json['name'] as String,
|
||||||
|
owner: json['owner'] as String,
|
||||||
|
uri: json['uri'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifySectionPlaylistImplToJson(
|
||||||
|
_$SpotifySectionPlaylistImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'description': instance.description,
|
||||||
|
'format': instance.format,
|
||||||
|
'images': instance.images,
|
||||||
|
'name': instance.name,
|
||||||
|
'owner': instance.owner,
|
||||||
|
'uri': instance.uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionArtistImpl(
|
||||||
|
name: json['name'] as String,
|
||||||
|
uri: json['uri'] as String,
|
||||||
|
images: (json['images'] as List<dynamic>)
|
||||||
|
.map((e) =>
|
||||||
|
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifySectionArtistImplToJson(
|
||||||
|
_$SpotifySectionArtistImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'name': instance.name,
|
||||||
|
'uri': instance.uri,
|
||||||
|
'images': instance.images,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionAlbumImpl(
|
||||||
|
artists: (json['artists'] as List<dynamic>)
|
||||||
|
.map((e) =>
|
||||||
|
SpotifySectionAlbumArtist.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
images: (json['images'] as List<dynamic>)
|
||||||
|
.map((e) =>
|
||||||
|
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
name: json['name'] as String,
|
||||||
|
uri: json['uri'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifySectionAlbumImplToJson(
|
||||||
|
_$SpotifySectionAlbumImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'artists': instance.artists,
|
||||||
|
'images': instance.images,
|
||||||
|
'name': instance.name,
|
||||||
|
'uri': instance.uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionAlbumArtistImpl(
|
||||||
|
name: json['name'] as String,
|
||||||
|
uri: json['uri'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifySectionAlbumArtistImplToJson(
|
||||||
|
_$SpotifySectionAlbumArtistImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'name': instance.name,
|
||||||
|
'uri': instance.uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifySectionItemImageImpl(
|
||||||
|
height: json['height'] as num?,
|
||||||
|
url: json['url'] as String,
|
||||||
|
width: json['width'] as num?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifySectionItemImageImplToJson(
|
||||||
|
_$SpotifySectionItemImageImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'height': instance.height,
|
||||||
|
'url': instance.url,
|
||||||
|
'width': instance.width,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedSectionItemImpl(
|
||||||
|
typename: json['typename'] as String,
|
||||||
|
playlist: json['playlist'] == null
|
||||||
|
? null
|
||||||
|
: SpotifySectionPlaylist.fromJson(
|
||||||
|
json['playlist'] as Map<String, dynamic>),
|
||||||
|
artist: json['artist'] == null
|
||||||
|
? null
|
||||||
|
: SpotifySectionArtist.fromJson(
|
||||||
|
json['artist'] as Map<String, dynamic>),
|
||||||
|
album: json['album'] == null
|
||||||
|
? null
|
||||||
|
: SpotifySectionAlbum.fromJson(json['album'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson(
|
||||||
|
_$SpotifyHomeFeedSectionItemImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'typename': instance.typename,
|
||||||
|
'playlist': instance.playlist,
|
||||||
|
'artist': instance.artist,
|
||||||
|
'album': instance.album,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedSectionImpl(
|
||||||
|
typename: json['typename'] as String,
|
||||||
|
title: json['title'] as String?,
|
||||||
|
uri: json['uri'] as String,
|
||||||
|
items: (json['items'] as List<dynamic>)
|
||||||
|
.map((e) =>
|
||||||
|
SpotifyHomeFeedSectionItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifyHomeFeedSectionImplToJson(
|
||||||
|
_$SpotifyHomeFeedSectionImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'typename': instance.typename,
|
||||||
|
'title': instance.title,
|
||||||
|
'uri': instance.uri,
|
||||||
|
'items': instance.items,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$SpotifyHomeFeedImpl(
|
||||||
|
greeting: json['greeting'] as String,
|
||||||
|
sections: (json['sections'] as List<dynamic>)
|
||||||
|
.map(
|
||||||
|
(e) => SpotifyHomeFeedSection.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SpotifyHomeFeedImplToJson(
|
||||||
|
_$SpotifyHomeFeedImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'greeting': instance.greeting,
|
||||||
|
'sections': instance.sections,
|
||||||
|
};
|
||||||
70
lib/services/track.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:metadata_god/metadata_god.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
extension TrackExtensions on Track {
|
||||||
|
Track fromFile(
|
||||||
|
File file, {
|
||||||
|
Metadata? metadata,
|
||||||
|
String? art,
|
||||||
|
}) {
|
||||||
|
album = Album()
|
||||||
|
..name = metadata?.album ?? 'Unknown'
|
||||||
|
..images = [if (art != null) Image()..url = art]
|
||||||
|
..genres = [if (metadata?.genre != null) metadata!.genre!]
|
||||||
|
..artists = [
|
||||||
|
Artist()
|
||||||
|
..name = metadata?.albumArtist ?? 'Unknown'
|
||||||
|
..id = metadata?.albumArtist ?? 'Unknown'
|
||||||
|
..type = 'artist',
|
||||||
|
]
|
||||||
|
..id = metadata?.album
|
||||||
|
..releaseDate = metadata?.year?.toString();
|
||||||
|
artists = [
|
||||||
|
Artist()
|
||||||
|
..name = metadata?.artist ?? 'Unknown'
|
||||||
|
..id = metadata?.artist ?? 'Unknown'
|
||||||
|
];
|
||||||
|
|
||||||
|
id = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||||
|
name = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||||
|
type = 'track';
|
||||||
|
uri = file.path;
|
||||||
|
durationMs = (metadata?.durationMs?.toInt() ?? 0);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrackSimpleExtensions on TrackSimple {
|
||||||
|
Track asTrack(AlbumSimple album) {
|
||||||
|
Track track = Track();
|
||||||
|
track.name = name;
|
||||||
|
track.album = album;
|
||||||
|
track.artists = artists;
|
||||||
|
track.availableMarkets = availableMarkets;
|
||||||
|
track.discNumber = discNumber;
|
||||||
|
track.durationMs = durationMs;
|
||||||
|
track.explicit = explicit;
|
||||||
|
track.externalUrls = externalUrls;
|
||||||
|
track.href = href;
|
||||||
|
track.id = id;
|
||||||
|
track.isPlayable = isPlayable;
|
||||||
|
track.linkedFrom = linkedFrom;
|
||||||
|
track.name = name;
|
||||||
|
track.previewUrl = previewUrl;
|
||||||
|
track.trackNumber = trackNumber;
|
||||||
|
track.type = type;
|
||||||
|
track.uri = uri;
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TracksToMediaExtension on Iterable<Track> {
|
||||||
|
List<RhythmMedia> asMediaList() {
|
||||||
|
return map((track) => RhythmMedia(track)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,11 +39,12 @@ class WindowManagerTools with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(instance);
|
WidgetsBinding.instance.addObserver(instance);
|
||||||
|
|
||||||
await windowManager.waitUntilReadyToShow(
|
await windowManager.waitUntilReadyToShow(
|
||||||
const WindowOptions(
|
WindowOptions(
|
||||||
title: 'RhythmBox',
|
title: 'RhythmBox',
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
minimumSize: Size(300, 700),
|
minimumSize: const Size(300, 700),
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
titleBarStyle:
|
||||||
|
PlatformInfo.isMacOS ? TitleBarStyle.hidden : TitleBarStyle.normal,
|
||||||
center: true,
|
center: true,
|
||||||
),
|
),
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class _NavShellState extends State<NavShell> {
|
|||||||
|
|
||||||
final List<Destination> _allDestinations = <Destination>[
|
final List<Destination> _allDestinations = <Destination>[
|
||||||
Destination('explore'.tr, 'explore', Icons.explore),
|
Destination('explore'.tr, 'explore', Icons.explore),
|
||||||
|
Destination('library'.tr, 'library', Icons.video_library),
|
||||||
Destination('search'.tr, 'search', Icons.search),
|
Destination('search'.tr, 'search', Icons.search),
|
||||||
Destination('settings'.tr, 'settings', Icons.settings),
|
Destination('settings'.tr, 'settings', Icons.settings),
|
||||||
];
|
];
|
||||||
@@ -40,6 +41,8 @@ class _NavShellState extends State<NavShell> {
|
|||||||
const BottomPlayer(key: Key('app-wide-bottom-player')),
|
const BottomPlayer(key: Key('app-wide-bottom-player')),
|
||||||
const Divider(height: 0.3, thickness: 0.3),
|
const Divider(height: 0.3, thickness: 0.3),
|
||||||
BottomNavigationBar(
|
BottomNavigationBar(
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
showUnselectedLabels: false,
|
showUnselectedLabels: false,
|
||||||
currentIndex: _focusDestination,
|
currentIndex: _focusDestination,
|
||||||
|
|||||||
63
lib/shells/system_shell.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
class SystemShell extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SystemShell({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SystemShell> createState() => _SystemShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SystemShellState extends State<SystemShell> {
|
||||||
|
late final ErrorNotifier _errorNotifier = Get.find();
|
||||||
|
|
||||||
|
StreamSubscription? _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_subscription = _errorNotifier.showing.listen((value) {
|
||||||
|
if (value == null) {
|
||||||
|
ScaffoldMessenger.of(context).clearMaterialBanners();
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showMaterialBanner(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (PlatformInfo.isMacOS) {
|
||||||
|
return DragToMoveArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 28,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
thickness: 0.3,
|
||||||
|
height: 0.3,
|
||||||
|
),
|
||||||
|
Expanded(child: widget.child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const i18nEnglish = {
|
const i18nEnglish = {
|
||||||
'appName': 'RhythmBox',
|
'appName': 'RhythmBox',
|
||||||
'explore': 'Explore',
|
'explore': 'Explore',
|
||||||
|
'library': 'Library',
|
||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
'search': 'Search',
|
'search': 'Search',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const i18nSimplifiedChinese = {
|
const i18nSimplifiedChinese = {
|
||||||
'appName': '韵律盒',
|
'appName': '韵律盒',
|
||||||
'explore': '探索',
|
'explore': '探索',
|
||||||
|
'library': '资料库',
|
||||||
'settings': '设置',
|
'settings': '设置',
|
||||||
'search': '搜索',
|
'search': '搜索',
|
||||||
};
|
};
|
||||||
|
|||||||
53
lib/widgets/album/album_card.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/services/artist.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
class AlbumCard extends StatelessWidget {
|
||||||
|
final AlbumSimple? item;
|
||||||
|
|
||||||
|
final Function? onTap;
|
||||||
|
|
||||||
|
const AlbumCard({super.key, required this.item, this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: (item?.images?.isNotEmpty ?? false)
|
||||||
|
? AutoCacheImage(item!.images!.first.url!)
|
||||||
|
: const Center(child: Icon(Icons.image)),
|
||||||
|
),
|
||||||
|
).paddingSymmetric(vertical: 8),
|
||||||
|
Text(
|
||||||
|
item?.name ?? 'Loading...',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item?.artists?.asString() ?? 'Please stand by...',
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingSymmetric(horizontal: 8),
|
||||||
|
onTap: () {
|
||||||
|
if (onTap == null) return;
|
||||||
|
onTap!();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import 'package:rhythm_box/platform.dart';
|
|||||||
class AutoCacheImage extends StatelessWidget {
|
class AutoCacheImage extends StatelessWidget {
|
||||||
final String url;
|
final String url;
|
||||||
final double? width, height;
|
final double? width, height;
|
||||||
|
final BoxFit? fit;
|
||||||
|
|
||||||
const AutoCacheImage(this.url, {super.key, this.width, this.height});
|
const AutoCacheImage(this.url,
|
||||||
|
{super.key, this.width, this.height, this.fit});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -15,12 +17,14 @@ class AutoCacheImage extends StatelessWidget {
|
|||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
fit: fit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Image.network(
|
return Image.network(
|
||||||
url,
|
url,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
fit: fit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
import 'package:rhythm_box/services/lyrics/model.dart';
|
import 'package:rhythm_box/services/lyrics/model.dart';
|
||||||
import 'package:rhythm_box/services/lyrics/provider.dart';
|
import 'package:rhythm_box/services/lyrics/provider.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||||
|
|
||||||
class SyncedLyrics extends StatefulWidget {
|
class SyncedLyrics extends StatefulWidget {
|
||||||
@@ -28,15 +30,16 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
final AutoScrollController _autoScrollController = AutoScrollController();
|
final AutoScrollController _autoScrollController = AutoScrollController();
|
||||||
|
|
||||||
late final int _textZoomLevel = widget.defaultTextZoom;
|
late final int _textZoomLevel = widget.defaultTextZoom;
|
||||||
late Duration _durationCurrent = audioPlayer.position;
|
|
||||||
|
|
||||||
SubtitleSimple? _lyric;
|
SubtitleSimple? _lyric;
|
||||||
|
String? _activeTrackId;
|
||||||
|
|
||||||
bool get _isLyricSynced =>
|
bool get _isLyricSynced =>
|
||||||
_lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0);
|
_lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0);
|
||||||
|
|
||||||
Future<void> _pullLyrics() async {
|
Future<void> _pullLyrics() async {
|
||||||
if (_playback.state.value.activeTrack == null) return;
|
if (_playback.state.value.activeTrack == null) return;
|
||||||
|
_activeTrackId = _playback.state.value.activeTrack!.id;
|
||||||
final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!);
|
final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!);
|
||||||
setState(() => _lyric = out);
|
setState(() => _lyric = out);
|
||||||
}
|
}
|
||||||
@@ -44,16 +47,53 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
List<StreamSubscription>? _subscriptions;
|
List<StreamSubscription>? _subscriptions;
|
||||||
|
|
||||||
Color get _unFocusColor =>
|
Color get _unFocusColor =>
|
||||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.5);
|
||||||
|
|
||||||
|
void _syncLyricsProgress() {
|
||||||
|
if (_isLyricSynced) {
|
||||||
|
for (var idx = 0; idx < _lyric!.lyrics.length; idx++) {
|
||||||
|
final lyricSlice = _lyric!.lyrics[idx];
|
||||||
|
final lyricNextSlice =
|
||||||
|
idx + 1 < _lyric!.lyrics.length ? _lyric!.lyrics[idx + 1] : null;
|
||||||
|
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||||
|
lyricSlice.time.inSeconds &&
|
||||||
|
(lyricNextSlice == null ||
|
||||||
|
lyricNextSlice.time.inSeconds >
|
||||||
|
_playback.durationCurrent.value.inSeconds);
|
||||||
|
if (isActive) {
|
||||||
|
_autoScrollController.scrollToIndex(
|
||||||
|
idx,
|
||||||
|
preferPosition: AutoScrollPosition.middle,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lyric!.lyrics.isNotEmpty || !_isLyricSynced) {
|
||||||
|
_autoScrollController.scrollToIndex(
|
||||||
|
0,
|
||||||
|
preferPosition: AutoScrollPosition.begin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_pullLyrics().then((_) {
|
||||||
|
_syncLyricsProgress();
|
||||||
|
});
|
||||||
_subscriptions = [
|
_subscriptions = [
|
||||||
audioPlayer.positionStream
|
_playback.state.listen((value) {
|
||||||
.listen((dur) => setState(() => _durationCurrent = dur)),
|
if (value.activeTrack == null) return;
|
||||||
|
if (value.activeTrack!.id != _activeTrackId) {
|
||||||
|
_pullLyrics().then((_) {
|
||||||
|
_syncLyricsProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
_pullLyrics();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -72,29 +112,52 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
|
cacheExtent: 10000,
|
||||||
controller: _autoScrollController,
|
controller: _autoScrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
|
const SliverGap(16),
|
||||||
|
if (_lyric == null)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_lyric != null && _lyric!.lyrics.isNotEmpty && !_isLyricSynced)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
'Lyrics isn\'t synced',
|
||||||
|
textAlign: MediaQuery.of(context).size.width >= 720
|
||||||
|
? TextAlign.center
|
||||||
|
: TextAlign.left,
|
||||||
|
).paddingSymmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_lyric != null && _lyric!.lyrics.isNotEmpty)
|
if (_lyric != null && _lyric!.lyrics.isNotEmpty)
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: _lyric!.lyrics.length,
|
itemCount: _lyric!.lyrics.length,
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) => Obx(() {
|
||||||
final lyricSlice = _lyric!.lyrics[idx];
|
final lyricSlice = _lyric!.lyrics[idx];
|
||||||
final lyricNextSlice = idx + 1 < _lyric!.lyrics.length
|
final lyricNextSlice = idx + 1 < _lyric!.lyrics.length
|
||||||
? _lyric!.lyrics[idx + 1]
|
? _lyric!.lyrics[idx + 1]
|
||||||
: null;
|
: null;
|
||||||
final isActive =
|
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||||
_durationCurrent.inSeconds >= lyricSlice.time.inSeconds &&
|
lyricSlice.time.inSeconds &&
|
||||||
(lyricNextSlice == null ||
|
(lyricNextSlice == null ||
|
||||||
lyricNextSlice.time.inSeconds >
|
lyricNextSlice.time.inSeconds >
|
||||||
_durationCurrent.inSeconds);
|
_playback.durationCurrent.value.inSeconds) &&
|
||||||
|
_isLyricSynced;
|
||||||
|
|
||||||
if (_durationCurrent.inSeconds == lyricSlice.time.inSeconds &&
|
if (_playback.durationCurrent.value.inSeconds ==
|
||||||
|
lyricSlice.time.inSeconds &&
|
||||||
_isLyricSynced) {
|
_isLyricSynced) {
|
||||||
_autoScrollController.scrollToIndex(
|
_autoScrollController.scrollToIndex(
|
||||||
idx,
|
idx,
|
||||||
preferPosition: AutoScrollPosition.middle,
|
preferPosition: AutoScrollPosition.middle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return AutoScrollTag(
|
return AutoScrollTag(
|
||||||
key: ValueKey(idx),
|
key: ValueKey(idx),
|
||||||
index: idx,
|
index: idx,
|
||||||
@@ -107,8 +170,9 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
)
|
)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: idx == _lyric!.lyrics.length - 1
|
padding: idx == _lyric!.lyrics.length - 1
|
||||||
? const EdgeInsets.all(8.0).copyWith(bottom: 100)
|
? const EdgeInsets.symmetric(vertical: 8)
|
||||||
: const EdgeInsets.all(8.0),
|
.copyWith(bottom: 80)
|
||||||
|
: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: AnimatedDefaultTextStyle(
|
child: AnimatedDefaultTextStyle(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -119,6 +183,9 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(8),
|
||||||
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final time = Duration(
|
final time = Duration(
|
||||||
seconds: lyricSlice.time.inSeconds -
|
seconds: lyricSlice.time.inSeconds -
|
||||||
@@ -131,26 +198,51 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
|||||||
audioPlayer.seek(time);
|
audioPlayer.seek(time);
|
||||||
},
|
},
|
||||||
child: Builder(builder: (context) {
|
child: Builder(builder: (context) {
|
||||||
return Text(
|
return AnimatedDefaultTextStyle(
|
||||||
lyricSlice.text,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
fontSize: isActive ? 20 : 16,
|
||||||
color: isActive
|
color: isActive
|
||||||
? Theme.of(context).colorScheme.onSurface
|
? Theme.of(context).colorScheme.onSurface
|
||||||
: _unFocusColor,
|
: _unFocusColor,
|
||||||
fontSize: 16,
|
|
||||||
),
|
),
|
||||||
).animate(target: isActive ? 1 : 0).scale(
|
duration: 500.ms,
|
||||||
duration: 300.ms,
|
curve: Curves.decelerate,
|
||||||
begin: const Offset(0.9, 0.9),
|
child: Text(
|
||||||
end: const Offset(1.3, 1.3),
|
lyricSlice.text,
|
||||||
);
|
textAlign:
|
||||||
}).paddingSymmetric(horizontal: 12),
|
MediaQuery.of(context).size.width >= 720
|
||||||
|
? TextAlign.center
|
||||||
|
: TextAlign.left,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).paddingSymmetric(horizontal: 24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
|
)
|
||||||
|
else if (_lyric != null && _lyric!.lyrics.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: CenteredContainer(
|
||||||
|
maxWidth: 280,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Lyrics Not Found',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"This song haven't lyrics that recorded in our database.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SliverGap(16),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
32
lib/widgets/no_login_fallback.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||||
|
|
||||||
|
class NoLoginFallback extends StatelessWidget {
|
||||||
|
const NoLoginFallback({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CenteredContainer(
|
||||||
|
maxWidth: 280,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.login,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Text(
|
||||||
|
'Connect with your Spotify',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'You need to connect RhythmBox with your spotify account in settings page, so that we can access your library.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,28 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rhythm_box/platform.dart';
|
||||||
import 'package:rhythm_box/providers/audio_player.dart';
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
|
||||||
import 'package:rhythm_box/services/audio_services/image.dart';
|
import 'package:rhythm_box/services/audio_services/image.dart';
|
||||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/controls.dart';
|
||||||
|
import 'package:rhythm_box/widgets/player/devices.dart';
|
||||||
import 'package:rhythm_box/widgets/player/track_details.dart';
|
import 'package:rhythm_box/widgets/player/track_details.dart';
|
||||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
import 'package:rhythm_box/widgets/volume_slider.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
class BottomPlayer extends StatefulWidget {
|
class BottomPlayer extends StatefulWidget {
|
||||||
final bool usePop;
|
final bool usePop;
|
||||||
|
final bool isMiniPlayer;
|
||||||
|
final Function? onTap;
|
||||||
|
|
||||||
const BottomPlayer({super.key, this.usePop = false});
|
const BottomPlayer({
|
||||||
|
super.key,
|
||||||
|
this.usePop = false,
|
||||||
|
this.isMiniPlayer = false,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BottomPlayer> createState() => _BottomPlayerState();
|
State<BottomPlayer> createState() => _BottomPlayerState();
|
||||||
@@ -35,38 +46,22 @@ class _BottomPlayerState extends State<BottomPlayer>
|
|||||||
late final AudioPlayerProvider _playback = Get.find();
|
late final AudioPlayerProvider _playback = Get.find();
|
||||||
late final QueryingTrackInfoProvider _query = Get.find();
|
late final QueryingTrackInfoProvider _query = Get.find();
|
||||||
|
|
||||||
|
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||||
|
|
||||||
String? get _albumArt =>
|
String? get _albumArt =>
|
||||||
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
||||||
index:
|
index:
|
||||||
(_playback.state.value.activeTrack?.album?.images?.length ?? 1) - 1,
|
(_playback.state.value.activeTrack?.album?.images?.length ?? 1) - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool get _isPlaying => _playback.isPlaying.value;
|
|
||||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
|
||||||
|
|
||||||
Duration _durationCurrent = Duration.zero;
|
|
||||||
Duration _durationTotal = Duration.zero;
|
|
||||||
|
|
||||||
List<StreamSubscription>? _subscriptions;
|
List<StreamSubscription>? _subscriptions;
|
||||||
|
|
||||||
Future<void> _togglePlayState() async {
|
|
||||||
if (!audioPlayer.isPlaying) {
|
|
||||||
await audioPlayer.resume();
|
|
||||||
} else {
|
|
||||||
await audioPlayer.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isLifted = false;
|
bool _isLifted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_subscriptions = [
|
_subscriptions = [
|
||||||
audioPlayer.durationStream
|
|
||||||
.listen((dur) => setState(() => _durationTotal = dur)),
|
|
||||||
audioPlayer.positionStream
|
|
||||||
.listen((dur) => setState(() => _durationCurrent = dur)),
|
|
||||||
_playback.state.listen((state) {
|
_playback.state.listen((state) {
|
||||||
if (state.playlist.medias.isNotEmpty && !_isLifted) {
|
if (state.playlist.medias.isNotEmpty && !_isLifted) {
|
||||||
_animationController.animateTo(1);
|
_animationController.animateTo(1);
|
||||||
@@ -109,78 +104,125 @@ class _BottomPlayerState extends State<BottomPlayer>
|
|||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_durationCurrent != Duration.zero)
|
TweenAnimationBuilder<double>(
|
||||||
TweenAnimationBuilder<double>(
|
tween: Tween(
|
||||||
tween: Tween(
|
begin: 0,
|
||||||
begin: 0,
|
end: _playback.durationCurrent.value.inMilliseconds /
|
||||||
end: _durationCurrent.inMilliseconds /
|
max(_playback.durationTotal.value.inMilliseconds, 1),
|
||||||
max(_durationTotal.inMilliseconds, 1),
|
|
||||||
),
|
|
||||||
duration: const Duration(milliseconds: 1000),
|
|
||||||
builder: (context, value, _) => LinearProgressIndicator(
|
|
||||||
minHeight: 3,
|
|
||||||
value: value,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
builder: (context, value, _) => LinearProgressIndicator(
|
||||||
|
minHeight: 3,
|
||||||
|
value: _isFetchingActiveTrack ? null : value,
|
||||||
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
|
||||||
tag: const Key('current-active-track-album-art'),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
child: _albumArt != null
|
|
||||||
? AutoCacheImage(_albumArt!, width: 64, height: 64)
|
|
||||||
: Container(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHigh,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
child: const Center(child: Icon(Icons.image)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PlayerTrackDetails(
|
child: Row(
|
||||||
track: _playback.state.value.activeTrack,
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: const Key('current-active-track-album-art'),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: _albumArt != null
|
||||||
|
? AutoCacheImage(
|
||||||
|
_albumArt!,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.image),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Expanded(
|
||||||
|
child: PlayerTrackDetails(
|
||||||
|
track: _playback.state.value.activeTrack,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
Row(
|
if (MediaQuery.of(context).size.width >= 720)
|
||||||
mainAxisSize: MainAxisSize.min,
|
const Expanded(child: PlayerControls())
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
else
|
||||||
children: [
|
const PlayerControls(),
|
||||||
IconButton(
|
if (MediaQuery.of(context).size.width >= 720) const Gap(12),
|
||||||
icon: const Icon(Icons.skip_next),
|
if (MediaQuery.of(context).size.width >= 720)
|
||||||
onPressed: _isFetchingActiveTrack
|
Expanded(
|
||||||
? null
|
child: Row(
|
||||||
: audioPlayer.skipToNext,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
),
|
children: [
|
||||||
IconButton.filled(
|
IconButton(
|
||||||
icon: _isFetchingActiveTrack
|
icon: const Icon(Icons.speaker, size: 18),
|
||||||
? const SizedBox(
|
onPressed: () {
|
||||||
height: 20,
|
showModalBottomSheet(
|
||||||
width: 20,
|
useRootNavigator: true,
|
||||||
child: CircularProgressIndicator(
|
context: context,
|
||||||
strokeWidth: 3,
|
builder: (context) => const PlayerDevicePopup(),
|
||||||
),
|
);
|
||||||
)
|
},
|
||||||
: Icon(
|
),
|
||||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
if (!widget.isMiniPlayer && PlatformInfo.isDesktop)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.picture_in_picture,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed: () async {
|
||||||
_isFetchingActiveTrack ? null : _togglePlayState,
|
if (!PlatformInfo.isDesktop) return;
|
||||||
|
|
||||||
|
final prevSize = await windowManager.getSize();
|
||||||
|
await windowManager.setMinimumSize(
|
||||||
|
const Size(300, 300),
|
||||||
|
);
|
||||||
|
await windowManager.setAlwaysOnTop(true);
|
||||||
|
if (!PlatformInfo.isLinux) {
|
||||||
|
await windowManager.setHasShadow(false);
|
||||||
|
}
|
||||||
|
await windowManager
|
||||||
|
.setAlignment(Alignment.topRight);
|
||||||
|
await windowManager
|
||||||
|
.setSize(const Size(400, 500));
|
||||||
|
await Future.delayed(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
() async {
|
||||||
|
GoRouter.of(context).pushNamed(
|
||||||
|
'playerMini',
|
||||||
|
extra: prevSize,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const VolumeSlider(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
],
|
],
|
||||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (widget.onTap != null) {
|
||||||
|
widget.onTap!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (widget.usePop) {
|
if (widget.usePop) {
|
||||||
GoRouter.of(context).pop();
|
GoRouter.of(context).pop();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
73
lib/widgets/player/controls.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
|
||||||
|
class PlayerControls extends StatefulWidget {
|
||||||
|
const PlayerControls({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerControls> createState() => _PlayerControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerControlsState extends State<PlayerControls> {
|
||||||
|
late final AudioPlayerProvider _playback = Get.find();
|
||||||
|
late final QueryingTrackInfoProvider _query = Get.find();
|
||||||
|
|
||||||
|
bool get _isPlaying => _playback.isPlaying.value;
|
||||||
|
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||||
|
|
||||||
|
Future<void> _togglePlayState() async {
|
||||||
|
if (!audioPlayer.isPlaying) {
|
||||||
|
await audioPlayer.resume();
|
||||||
|
} else {
|
||||||
|
await audioPlayer.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(
|
||||||
|
() => Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MediaQuery.of(context).size.width >= 720
|
||||||
|
? MainAxisAlignment.center
|
||||||
|
: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (MediaQuery.of(context).size.width >= 720)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_previous),
|
||||||
|
onPressed:
|
||||||
|
_isFetchingActiveTrack ? null : audioPlayer.skipToPrevious,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_next),
|
||||||
|
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
||||||
|
),
|
||||||
|
IconButton.filled(
|
||||||
|
icon: (_isFetchingActiveTrack && _isPlaying)
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
||||||
|
),
|
||||||
|
onPressed: _togglePlayState,
|
||||||
|
),
|
||||||
|
if (MediaQuery.of(context).size.width >= 720)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_next),
|
||||||
|
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/widgets/player/devices.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
|
class PlayerDevicePopup extends StatefulWidget {
|
||||||
|
const PlayerDevicePopup({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerDevicePopup> createState() => _PlayerDevicePopupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerDevicePopupState extends State<PlayerDevicePopup> {
|
||||||
|
late Future<List<AudioDevice>> devicesFuture;
|
||||||
|
late Stream<List<AudioDevice>> devicesStream;
|
||||||
|
late Future<AudioDevice> selectedDeviceFuture;
|
||||||
|
late Stream<AudioDevice> selectedDeviceStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
devicesFuture = audioPlayer.devices;
|
||||||
|
devicesStream = audioPlayer.devicesStream;
|
||||||
|
selectedDeviceFuture = audioPlayer.selectedDevice;
|
||||||
|
selectedDeviceStream = audioPlayer.selectedDeviceStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Devices',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<List<AudioDevice>>(
|
||||||
|
stream: devicesStream,
|
||||||
|
builder: (context, devicesSnapshot) {
|
||||||
|
return FutureBuilder<List<AudioDevice>>(
|
||||||
|
future: devicesFuture,
|
||||||
|
builder: (context, devicesFutureSnapshot) {
|
||||||
|
final devices =
|
||||||
|
devicesSnapshot.data ?? devicesFutureSnapshot.data;
|
||||||
|
|
||||||
|
return StreamBuilder<AudioDevice>(
|
||||||
|
stream: selectedDeviceStream,
|
||||||
|
builder: (context, selectedDeviceSnapshot) {
|
||||||
|
return FutureBuilder<AudioDevice>(
|
||||||
|
future: selectedDeviceFuture,
|
||||||
|
builder: (context, selectedDeviceFutureSnapshot) {
|
||||||
|
final selectedDevice = selectedDeviceSnapshot.data ??
|
||||||
|
selectedDeviceFutureSnapshot.data;
|
||||||
|
|
||||||
|
if (devices == null || selectedDevice == null) {
|
||||||
|
return const CircularProgressIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: devices.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final device = devices[idx];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.speaker),
|
||||||
|
title: Text(device.description),
|
||||||
|
subtitle: Text(device.name),
|
||||||
|
selected: selectedDevice == device,
|
||||||
|
onTap: () => audioPlayer.setAudioDevice(device),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
250
lib/widgets/player/sibling_tracks.dart
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:rhythm_box/providers/audio_player.dart';
|
||||||
|
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||||
|
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||||
|
import 'package:rhythm_box/services/database/database.dart';
|
||||||
|
import 'package:rhythm_box/services/duration.dart';
|
||||||
|
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
|
||||||
|
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||||
|
import 'package:rhythm_box/services/artist.dart';
|
||||||
|
import 'package:rhythm_box/services/utils.dart';
|
||||||
|
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||||
|
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
|
class SiblingTracks extends StatefulWidget {
|
||||||
|
const SiblingTracks({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SiblingTracks> createState() => _SiblingTracksState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SiblingTracksState extends State<SiblingTracks> {
|
||||||
|
late final QueryingTrackInfoProvider _query = Get.find();
|
||||||
|
late final ActiveSourcedTrackProvider _activeSource = Get.find();
|
||||||
|
late final AudioPlayerProvider _playback = Get.find();
|
||||||
|
|
||||||
|
final TextEditingController _searchTermController = TextEditingController();
|
||||||
|
|
||||||
|
Track? get _activeTrack =>
|
||||||
|
_activeSource.state.value ?? _playback.state.value.activeTrack;
|
||||||
|
|
||||||
|
List<SourceInfo> _siblings = List.empty(growable: true);
|
||||||
|
|
||||||
|
final sourceInfoToLabelMap = {
|
||||||
|
YoutubeSourceInfo: 'YouTube',
|
||||||
|
PipedSourceInfo: 'Piped',
|
||||||
|
NeteaseSourceInfo: 'Netease',
|
||||||
|
KugouSourceInfo: 'Kugou',
|
||||||
|
};
|
||||||
|
|
||||||
|
List<StreamSubscription>? _subscriptions;
|
||||||
|
|
||||||
|
String? _lastActiveTrackId;
|
||||||
|
|
||||||
|
void _updateSiblings() {
|
||||||
|
_siblings = List.from(
|
||||||
|
!_query.isQueryingTrackInfo.value
|
||||||
|
? [
|
||||||
|
(_activeTrack as SourcedTrack).sourceInfo,
|
||||||
|
..._activeSource.state.value!.siblings,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
growable: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSearchTerm() {
|
||||||
|
if (_lastActiveTrackId == _activeTrack?.id) return;
|
||||||
|
|
||||||
|
final title = ServiceUtils.getTitle(
|
||||||
|
_activeTrack?.name ?? '',
|
||||||
|
artists: _activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
|
||||||
|
onlyCleanArtist: true,
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
final defaultSearchTerm =
|
||||||
|
'$title - ${_activeTrack?.artists?.asString() ?? ''}';
|
||||||
|
|
||||||
|
_searchTermController.text = defaultSearchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
|
Future<void> _searchSiblings() async {
|
||||||
|
if (_isSearching) return;
|
||||||
|
if (_searchTermController.text.trim().isEmpty) return;
|
||||||
|
|
||||||
|
_siblings.clear();
|
||||||
|
setState(() => _isSearching = true);
|
||||||
|
|
||||||
|
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||||
|
final searchTerm = _searchTermController.text.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (preferences.audioSource == AudioSource.youtube ||
|
||||||
|
preferences.audioSource == AudioSource.piped) {
|
||||||
|
final resultsYt = await youtubeClient.search.search(searchTerm.trim());
|
||||||
|
|
||||||
|
final searchResults = await Future.wait(
|
||||||
|
resultsYt
|
||||||
|
.map(YoutubeVideoInfo.fromVideo)
|
||||||
|
.mapIndexed((i, video) async {
|
||||||
|
final siblingType =
|
||||||
|
await YoutubeSourcedTrack.toSiblingType(i, video);
|
||||||
|
return siblingType.info;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||||
|
_siblings = List.from(
|
||||||
|
searchResults
|
||||||
|
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||||
|
..insert(
|
||||||
|
0,
|
||||||
|
activeSourceInfo,
|
||||||
|
),
|
||||||
|
growable: true,
|
||||||
|
);
|
||||||
|
} else if (preferences.audioSource == AudioSource.netease) {
|
||||||
|
final client = NeteaseSourcedTrack.getClient();
|
||||||
|
final resp = await client.get(
|
||||||
|
'/search?keywords=${Uri.encodeComponent(searchTerm)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}');
|
||||||
|
final searchResults = resp.body['result']['songs']
|
||||||
|
.map(NeteaseSourcedTrack.toSourceInfo)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||||
|
_siblings = List.from(
|
||||||
|
searchResults
|
||||||
|
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||||
|
..insert(
|
||||||
|
0,
|
||||||
|
activeSourceInfo,
|
||||||
|
),
|
||||||
|
growable: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Get.find<ErrorNotifier>().showError(err.toString());
|
||||||
|
} finally {
|
||||||
|
setState(() => _isSearching = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_updateSearchTerm();
|
||||||
|
_updateSiblings();
|
||||||
|
_subscriptions = [
|
||||||
|
_playback.state.listen((value) async {
|
||||||
|
if (value.activeTrack != null) {
|
||||||
|
_updateSearchTerm();
|
||||||
|
_updateSiblings();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchTermController.dispose();
|
||||||
|
if (_subscriptions != null) {
|
||||||
|
for (final subscription in _subscriptions!) {
|
||||||
|
subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchTermController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'search'.tr,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) {
|
||||||
|
_searchSiblings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isSearching) const LinearProgressIndicator(minHeight: 3),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _siblings.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final item = _siblings[idx];
|
||||||
|
final src = sourceInfoToLabelMap[item.runtimeType];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
item.title,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
leading: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: AutoCacheImage(
|
||||||
|
item.thumbnail,
|
||||||
|
height: 64,
|
||||||
|
width: 64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
item.duration.toHumanReadableString(),
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
),
|
||||||
|
subtitle: Row(
|
||||||
|
children: [
|
||||||
|
if (src != null) Text(src),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
' · ${item.artist}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
enabled: !_query.isQueryingTrackInfo.value,
|
||||||
|
tileColor: !_query.isQueryingTrackInfo.value &&
|
||||||
|
item.id == (_activeTrack as SourcedTrack).sourceInfo.id
|
||||||
|
? Theme.of(context).colorScheme.secondaryContainer
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
if (!_query.isQueryingTrackInfo.value &&
|
||||||
|
item.id != (_activeTrack as SourcedTrack).sourceInfo.id) {
|
||||||
|
_activeSource.swapSibling(item);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||