Compare commits
61 Commits
3f41573f00
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b40e81a7 | |||
| 1913a7e909 | |||
| d860936010 | |||
| 873ad1cf8c | |||
| e0c9edad78 | |||
| 70ea02962f | |||
| 59783c48f7 | |||
| b099f63f61 | |||
| b69bee7e59 | |||
| 3d23152802 | |||
| 90dc3f43a7 | |||
| 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
|
||||
22
README.md
@@ -5,16 +5,30 @@ Yet another spotify third-party client. Support multi-platform because built wit
|
||||
This project is inspired by and taken supported by [spotube](https://spotube.krtirtho.dev).
|
||||
Their original app is good enough. But I just want to redesign the user interface and make it ready add to more features and more backend support.
|
||||
|
||||
## Highlight
|
||||
|
||||
Compare to original spotube. This project added more audio source e.g. netease cloud music, kugou and provide the ability to use in China Mainland.
|
||||
|
||||
At the same time, this project also focus on playing experience of VOCALOID songs.
|
||||
We improve the search and rank algorithm to make the querying will less pick the cover version instead of original ones.
|
||||
|
||||
Due to the end service of jiosaavn in Asian region (maybe other regions also affected). We removed the jiosaavn audio source.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Playing music
|
||||
- [ ] Add netease music as source
|
||||
- [x] Add netease music as source
|
||||
- [ ] Add bilibili as source
|
||||
- [ ] Add kuwo music as source
|
||||
- [x] Add kugou music as source
|
||||
- [x] Optimize fallback strategy
|
||||
- [x] Re-design user interface
|
||||
- [x] Simplified UI and UX
|
||||
- [ ] Support for large screen device
|
||||
- [x] Simplified UI and UX
|
||||
- [x] Support for large screen device
|
||||
|
||||
## License
|
||||
|
||||
This project is open-sourced under APGLv3 license. The original spotube project is open-sourced under license BSD-Clause4 and copyright by Kingkor Roy Tirtho.
|
||||
|
||||
This project is all rights reversed by LittleSheep and Solsynth LLC.
|
||||
This project is all rights reversed by LittleSheep and Solsynth LLC.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<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.FOREGROUND_SERVICE" />
|
||||
<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"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<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-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"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</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
|
||||
the Flutter engine draws its first frame -->
|
||||
<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>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
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
|
||||
the Flutter engine draws its first frame -->
|
||||
<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>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
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_broadcasts (0.0.1):
|
||||
- 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):
|
||||
- Flutter
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- Flutter
|
||||
- metadata_god (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (5.0.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -23,6 +37,28 @@ PODS:
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- 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:
|
||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||
@@ -30,12 +66,24 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- 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_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`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/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:
|
||||
audio_service:
|
||||
@@ -48,10 +96,18 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_broadcasts:
|
||||
: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:
|
||||
:path: ".symlinks/plugins/media_kit_libs_ios_audio/ios"
|
||||
media_kit_native_event_loop:
|
||||
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
||||
metadata_god:
|
||||
:path: ".symlinks/plugins/metadata_god/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -60,6 +116,12 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
: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:
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
@@ -67,12 +129,21 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3
|
||||
media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837
|
||||
metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||
|
||||
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
|
||||
|
||||
|
||||
@@ -161,7 +161,6 @@
|
||||
1CF40EE9C145DC3FDC6C41BF /* Pods-RunnerTests.release.xcconfig */,
|
||||
DAFDCBCA918FE99EC399DF6B /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -474,11 +473,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -495,7 +496,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -513,7 +514,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -529,7 +530,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -657,11 +658,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -680,11 +683,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
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" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"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">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<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>
|
||||
</view>
|
||||
</viewController>
|
||||
@@ -32,6 +38,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="2050" height="2048"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Rhythm Box</string>
|
||||
<string>Groovy Box</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>rhythm_box</string>
|
||||
<string>Groovy Box</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -22,12 +26,24 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -41,9 +57,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.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_stream.dart';
|
||||
import 'package:rhythm_box/providers/auth.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/palette.dart';
|
||||
import 'package:rhythm_box/providers/recent_played.dart';
|
||||
import 'package:rhythm_box/providers/scrobbler.dart';
|
||||
import 'package:rhythm_box/providers/skip_segments.dart';
|
||||
import 'package:rhythm_box/providers/spotify.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/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/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/server/routes/playback.dart';
|
||||
import 'package:rhythm_box/services/server/server.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/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();
|
||||
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 {
|
||||
const MyApp({super.key});
|
||||
class RhythmApp extends StatelessWidget {
|
||||
const RhythmApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetMaterialApp.router(
|
||||
title: 'DietaryGuard',
|
||||
title: 'RhythmBox',
|
||||
routerDelegate: router.routerDelegate,
|
||||
routeInformationParser: router.routeInformationParser,
|
||||
routeInformationProvider: router.routeInformationProvider,
|
||||
@@ -54,6 +84,13 @@ class MyApp extends StatelessWidget {
|
||||
themeMode: ThemeMode.system,
|
||||
translations: AppTranslations(),
|
||||
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(() => SyncedLyricsProvider());
|
||||
|
||||
Get.put(ErrorNotifier());
|
||||
|
||||
Get.put(DatabaseProvider());
|
||||
Get.put(AuthenticationProvider());
|
||||
|
||||
Get.put(AudioPlayerProvider());
|
||||
Get.put(ActiveSourcedTrackProvider());
|
||||
@@ -75,6 +115,9 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
Get.put(QueryingTrackInfoProvider());
|
||||
Get.put(SourcedTrackProvider());
|
||||
Get.put(EndlessPlaybackProvider());
|
||||
Get.put(VolumeProvider());
|
||||
Get.put(RecentlyPlayedProvider());
|
||||
|
||||
Get.put(ServerPlaybackRoutesProvider());
|
||||
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/services/audio_player/state.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:rhythm_box/services/audio_player/audio_player.dart';
|
||||
|
||||
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;
|
||||
|
||||
Rx<AudioPlayerState> state = Rx(AudioPlayerState(
|
||||
@@ -54,6 +61,11 @@ class AudioPlayerProvider extends GetxController {
|
||||
state.value = state.value.copyWith(playlist: 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();
|
||||
@@ -239,11 +251,12 @@ class AudioPlayerProvider extends GetxController {
|
||||
|
||||
// Giving the initial track a boost so MediaKit won't skip
|
||||
// because of timeout
|
||||
// final intendedActiveTrack = medias.elementAt(initialIndex);
|
||||
// if (intendedActiveTrack.track is! LocalTrack) {
|
||||
// await Get.find<SourcedTrackProvider>()
|
||||
// .fetch(RhythmMedia(intendedActiveTrack.track));
|
||||
// }
|
||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||
final intendedActiveTrack = medias.elementAt(initialIndex);
|
||||
if (intendedActiveTrack.track is! LocalTrack) {
|
||||
await Get.find<SourcedTrackProvider>()
|
||||
.fetch(RhythmMedia(intendedActiveTrack.track));
|
||||
}
|
||||
|
||||
if (medias.isEmpty) return;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:palette_generator/palette_generator.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/palette.dart';
|
||||
import 'package:rhythm_box/providers/scrobbler.dart';
|
||||
@@ -126,7 +127,8 @@ class AudioPlayerStreamProvider extends GetxController {
|
||||
.addTrack(playback.state.value.activeTrack!);
|
||||
lastScrobbled = uid;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
47
lib/providers/error_notifier.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ErrorNotifier extends GetxController {
|
||||
Rx<MaterialBanner?> showing = Rx(null);
|
||||
|
||||
Timer? _autoDismissTimer;
|
||||
|
||||
void logError(String msg, {StackTrace? trace}) {
|
||||
log('$msg${trace != null ? '\nTrace:\n$trace' : ''}');
|
||||
showError(msg);
|
||||
}
|
||||
|
||||
void showError(String msg) {
|
||||
_autoDismissTimer?.cancel();
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
_autoDismissTimer = Timer(const Duration(seconds: 3), () {
|
||||
showing.value = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ class PaletteProvider extends GetxController {
|
||||
|
||||
void updatePalette(PaletteGenerator? newPalette) {
|
||||
palette.value = newPalette;
|
||||
print('call update!');
|
||||
print(newPalette);
|
||||
if (newPalette != null) {
|
||||
Get.changeTheme(
|
||||
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:developer';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
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/database/database.dart';
|
||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||
@@ -44,7 +44,8 @@ class ScrobblerProvider extends GetxController {
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log('[Scrobble] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||
scrobbler.value = null;
|
||||
}
|
||||
} else {
|
||||
@@ -63,8 +64,9 @@ class ScrobblerProvider extends GetxController {
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
trackNumber: track.trackNumber,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
log('[Scrobble] Error: $e; Trace:\n$stackTrace');
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get/get.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/services/database/database.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)))
|
||||
.get();
|
||||
} catch (e, stack) {
|
||||
log('[SkipSegment] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>().logError('[SkipSegment] Error: $e', trace: stack);
|
||||
return List.castFrom<dynamic, SkipSegmentTableData>([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,59 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class SpotifyProvider extends GetxController {
|
||||
late final SpotifyApi api;
|
||||
late SpotifyApi api;
|
||||
|
||||
List<StreamSubscription>? _subscriptions;
|
||||
|
||||
@override
|
||||
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(
|
||||
'f73d4bff91d64d89be9930036f553534',
|
||||
'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/sourced_track/enums.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
typedef UserPreferences = PreferencesTableData;
|
||||
|
||||
@@ -49,13 +49,7 @@ class UserPreferencesProvider extends GetxController {
|
||||
.listen((event) async {
|
||||
state.value = event;
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
await windowManager.setTitleBarStyle(
|
||||
state.value.systemTitleBar
|
||||
? TitleBarStyle.normal
|
||||
: TitleBarStyle.hidden,
|
||||
);
|
||||
}
|
||||
await WakelockPlus.toggle(enable: state.value.playerWakelock);
|
||||
|
||||
await audioPlayer.setAudioNormalization(state.value.normalizeAudio);
|
||||
});
|
||||
@@ -147,6 +141,10 @@ class UserPreferencesProvider extends GetxController {
|
||||
setData(PreferencesTableCompanion(locale: Value(locale)));
|
||||
}
|
||||
|
||||
void setNeteaseApiInstance(String instance) {
|
||||
setData(PreferencesTableCompanion(neteaseApiInstance: Value(instance)));
|
||||
}
|
||||
|
||||
void setPipedInstance(String instance) {
|
||||
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
|
||||
}
|
||||
@@ -167,14 +165,6 @@ class UserPreferencesProvider extends GetxController {
|
||||
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) {
|
||||
setData(PreferencesTableCompanion(normalizeAudio: Value(normalize)));
|
||||
audioPlayer.setAudioNormalization(normalize);
|
||||
@@ -184,7 +174,12 @@ class UserPreferencesProvider extends GetxController {
|
||||
setData(PreferencesTableCompanion(endlessPlayback: Value(endless)));
|
||||
}
|
||||
|
||||
void setEnableConnect(bool enable) {
|
||||
setData(PreferencesTableCompanion(enableConnect: Value(enable)));
|
||||
void setPlayerWakelock(bool wakelock) {
|
||||
setData(PreferencesTableCompanion(playerWakelock: Value(wakelock)));
|
||||
WakelockPlus.toggle(enable: wakelock);
|
||||
}
|
||||
|
||||
void setOverrideCacheProvider(bool override) {
|
||||
setData(PreferencesTableCompanion(overrideCacheProvider: Value(override)));
|
||||
}
|
||||
}
|
||||
|
||||
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:flutter/material.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/library/view.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/playlist/view.dart';
|
||||
import 'package:rhythm_box/screens/search/view.dart';
|
||||
@@ -18,6 +24,11 @@ final router = GoRouter(routes: [
|
||||
name: 'explore',
|
||||
builder: (context, state) => const ExploreScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/library',
|
||||
name: 'library',
|
||||
builder: (context, state) => const LibraryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
@@ -30,11 +41,23 @@ final router = GoRouter(routes: [
|
||||
playlistId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
name: 'albumView',
|
||||
builder: (context, state) => AlbumViewScreen(
|
||||
albumId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
builder: (context, state) => const AboutScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
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:gap/gap.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/widgets/auto_cache_image.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.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';
|
||||
|
||||
class ExploreScreen extends StatefulWidget {
|
||||
@@ -15,15 +21,71 @@ class ExploreScreen extends StatefulWidget {
|
||||
|
||||
class _ExploreScreenState extends State<ExploreScreen> {
|
||||
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 {
|
||||
final market = Get.find<UserPreferencesProvider>().state.value.market;
|
||||
final locale = Get.find<UserPreferencesProvider>().state.value.locale;
|
||||
|
||||
_featuredPlaylist =
|
||||
(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
|
||||
@@ -39,46 +101,55 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('explore'.tr),
|
||||
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||
),
|
||||
body: Skeletonizer(
|
||||
enabled: _isLoading,
|
||||
child: ListView.builder(
|
||||
itemCount: _featuredPlaylist?.length ?? 20,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = _featuredPlaylist?[idx];
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
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),
|
||||
),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['recently']!,
|
||||
title: 'Recent Played',
|
||||
list: _recentlyPlaylist,
|
||||
),
|
||||
title: Text(item?.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item?.description ?? 'Please stand by...',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['newReleases']!,
|
||||
title: 'New Releases',
|
||||
list: _newReleasesPlaylist,
|
||||
),
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
GoRouter.of(context).pushNamed(
|
||||
'playlistView',
|
||||
pathParameters: {'id': item.id!},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
SliverList.builder(
|
||||
itemCount: _forYouView?.length ?? 0,
|
||||
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: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';
|
||||
|
||||
class LyricsScreen extends StatelessWidget {
|
||||
class LyricsScreen extends StatefulWidget {
|
||||
const LyricsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LyricsScreen> createState() => _LyricsScreenState();
|
||||
}
|
||||
|
||||
class _LyricsScreenState extends State<LyricsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
@@ -22,13 +30,15 @@ class LyricsScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: const SizedBox(
|
||||
height: 85,
|
||||
bottomNavigationBar: SizedBox(
|
||||
height: 85 + max(MediaQuery.of(context).padding.bottom, 16),
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
child: BottomPlayer(
|
||||
child: const BottomPlayer(
|
||||
key: Key('lyrics-page-bottom-player'),
|
||||
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:media_kit/media_kit.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/siblings.dart';
|
||||
import 'package:rhythm_box/screens/player/source_details.dart';
|
||||
import 'package:rhythm_box/services/artist.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/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';
|
||||
|
||||
class PlayerScreen extends StatefulWidget {
|
||||
@@ -26,6 +32,7 @@ class PlayerScreen extends StatefulWidget {
|
||||
class _PlayerScreenState extends State<PlayerScreen> {
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
late final QueryingTrackInfoProvider _query = Get.find();
|
||||
late final AuthenticationProvider _auth = Get.find();
|
||||
|
||||
String? get _albumArt =>
|
||||
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
||||
@@ -37,13 +44,6 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||
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 {
|
||||
if (!audioPlayer.isPlaying) {
|
||||
await audioPlayer.resume();
|
||||
@@ -53,45 +53,16 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
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;
|
||||
|
||||
@override
|
||||
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();
|
||||
}
|
||||
static const double maxAlbumSize = 360;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final albumSize = max(size.shortestSide, maxAlbumSize).toDouble();
|
||||
|
||||
final isLargeScreen = size.width >= 720;
|
||||
|
||||
return DismissiblePage(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@@ -99,217 +70,346 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
direction: DismissiblePageDismissDirection.down,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
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,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(_durationCurrent),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(_durationTotal),
|
||||
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 IconButton(
|
||||
icon: Icon(
|
||||
shuffled ? Icons.shuffle_on_outlined : Icons.shuffle,
|
||||
),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: () {
|
||||
if (shuffled) {
|
||||
audioPlayer.setShuffle(false);
|
||||
} else {
|
||||
audioPlayer.setShuffle(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
Obx(
|
||||
() => Center(
|
||||
child: LimitedBox(
|
||||
maxHeight: maxAlbumSize,
|
||||
maxWidth: maxAlbumSize,
|
||||
child: Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
child: _albumArt != null
|
||||
? AutoCacheImage(
|
||||
_albumArt!,
|
||||
width: albumSize,
|
||||
height: albumSize,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: const Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
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
|
||||
? null
|
||||
: () async {
|
||||
await audioPlayer.setLoopMode(
|
||||
switch (_loopMode) {
|
||||
PlaylistMode.loop => PlaylistMode.single,
|
||||
PlaylistMode.single => PlaylistMode.none,
|
||||
PlaylistMode.none => PlaylistMode.loop,
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
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(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(20),
|
||||
Row(
|
||||
children: [
|
||||
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: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isLargeScreen) const Gap(24),
|
||||
if (isLargeScreen)
|
||||
const Expanded(
|
||||
child: SyncedLyrics(defaultTextZoom: 67),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).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/services/audio_player/audio_player.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';
|
||||
|
||||
@@ -36,19 +37,46 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
: false;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingTracks = true;
|
||||
bool _isUpdating = false;
|
||||
|
||||
Playlist? _playlist;
|
||||
List<Track>? _tracks;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pullPlaylist();
|
||||
_pullTracks();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -60,6 +88,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Playlist'),
|
||||
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
@@ -69,95 +98,117 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Material(
|
||||
borderRadius: radius,
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
return CenteredContainer(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Material(
|
||||
borderRadius: radius,
|
||||
child: Hero(
|
||||
tag: Key('playlist-cover-${_playlist!.id}'),
|
||||
child: AutoCacheImage(
|
||||
_playlist!.images!.first.url!,
|
||||
width: 160.0,
|
||||
height: 160.0,
|
||||
),
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: (_playlist?.images?.isNotEmpty ?? false)
|
||||
? AutoCacheImage(
|
||||
_playlist!.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(
|
||||
_playlist!.name ?? 'Playlist',
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
Text(
|
||||
_playlist!.description ?? 'A Playlist',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers!.total!)} saves",
|
||||
),
|
||||
Text(
|
||||
'#${_playlist!.id}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 10),
|
||||
),
|
||||
],
|
||||
const Gap(24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_playlist!.name ?? 'Playlist',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
Text(
|
||||
_playlist!.description ?? 'A Playlist',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers?.total! ?? 0)} saves",
|
||||
),
|
||||
Text(
|
||||
'#${_playlist!.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'),
|
||||
],
|
||||
).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(_playlist!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
.addPlaylists([_playlist!]);
|
||||
|
||||
setState(() => _isUpdating = false);
|
||||
},
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
label: const Text('Shuffle'),
|
||||
onPressed: _isUpdating
|
||||
? null
|
||||
: () async {
|
||||
if (_isCurrentPlaylist &&
|
||||
_playback.isPlaying.value) {
|
||||
audioPlayer.pause();
|
||||
return;
|
||||
} else if (_isCurrentPlaylist &&
|
||||
!_playback.isPlaying.value) {
|
||||
audioPlayer.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isUpdating = true);
|
||||
|
||||
final tracks = (await _spotify
|
||||
.api.playlists
|
||||
.getTracksByPlaylistId(
|
||||
widget.playlistId)
|
||||
.all())
|
||||
.toList();
|
||||
audioPlayer.setShuffle(true);
|
||||
|
||||
await _playback.load(tracks,
|
||||
autoPlay: true);
|
||||
await _playback.load(
|
||||
_tracks!,
|
||||
autoPlay: true,
|
||||
initialIndex:
|
||||
Random().nextInt(_tracks!.length),
|
||||
);
|
||||
_playback.addCollection(_playlist!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
.addPlaylists([_playlist!]);
|
||||
@@ -165,50 +216,25 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
setState(() => _isUpdating = false);
|
||||
},
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
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),
|
||||
],
|
||||
],
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Songs (${_playlist!.tracks!.total})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||
),
|
||||
PlaylistTrackList(playlistId: widget.playlistId),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Songs (${_playlist!.tracks?.total ?? (_tracks?.length ?? 0)})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||
),
|
||||
PlaylistTrackList(
|
||||
isLoading: _isLoadingTracks,
|
||||
playlistId: widget.playlistId,
|
||||
tracks: _tracks,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/spotify.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:spotify/spotify.dart';
|
||||
|
||||
@@ -61,11 +62,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).paddingSymmetric(horizontal: 24, vertical: 8),
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (_searchResult != null)
|
||||
TrackSliverList(tracks: List<Track>.from(_searchResult!)),
|
||||
],
|
||||
child: CenteredContainer(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
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: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 {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -8,8 +19,293 @@ class SettingsScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
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(
|
||||
() => CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: const Icon(Icons.update),
|
||||
title: const Text('Override Cache Provider'),
|
||||
subtitle: const Text(
|
||||
'Decide whether use original cached source or query a new one from current audio provider'),
|
||||
value: _preferences.state.value.overrideCacheProvider,
|
||||
onChanged: (value) =>
|
||||
_preferences.setOverrideCacheProvider(value ?? false),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Column(
|
||||
children: [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.cloud),
|
||||
title: Text('Netease Cloud Music API'),
|
||||
subtitle: Text(
|
||||
'Use your own endpoint to prevent IP throttling and more'),
|
||||
),
|
||||
TextFormField(
|
||||
initialValue: _preferences.state.value.neteaseApiInstance,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Endpoint URL',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_preferences.setNeteaseApiInstance(value);
|
||||
},
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).paddingOnly(left: 24, right: 24, bottom: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:flutter/foundation.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/server/server.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
@@ -93,7 +94,7 @@ abstract class AudioPlayerInterface {
|
||||
),
|
||||
) {
|
||||
_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 {
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> skipToPrevious() async {
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
|
||||
// ignore: implementation_imports
|
||||
import 'package:rhythm_box/services/audio_player/playback_state.dart';
|
||||
@@ -49,7 +50,8 @@ class CustomPlayer extends Player {
|
||||
}
|
||||
}),
|
||||
stream.error.listen((event) {
|
||||
log('[MediaKitError] $event');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Playback][CustomLayer] Error: $event');
|
||||
}),
|
||||
];
|
||||
PackageInfo.fromPlatform().then((packageInfo) {
|
||||
|
||||
@@ -15,7 +15,7 @@ class WindowsAudioService {
|
||||
final subscriptions = <StreamSubscription>[];
|
||||
|
||||
WindowsAudioService() : smtc = SMTCWindows(enabled: false) {
|
||||
smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
||||
smtc.setPlaybackStatus(PlaybackStatus.stopped);
|
||||
final buttonStream = smtc.buttonPressStream.listen((event) {
|
||||
switch (event) {
|
||||
case PressedButton.play:
|
||||
@@ -42,16 +42,16 @@ class WindowsAudioService {
|
||||
audioPlayer.playerStateStream.listen((state) async {
|
||||
switch (state) {
|
||||
case AudioPlaybackState.playing:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Playing);
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.playing);
|
||||
break;
|
||||
case AudioPlaybackState.paused:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Paused);
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.paused);
|
||||
break;
|
||||
case AudioPlaybackState.stopped:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.stopped);
|
||||
break;
|
||||
case AudioPlaybackState.completed:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Changing);
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.changing);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -55,7 +55,24 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
if (from < 2) {
|
||||
await m.addColumn(
|
||||
preferencesTable,
|
||||
preferencesTable.overrideCacheProvider,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
|
||||
@@ -2,7 +2,10 @@ part of '../database.dart';
|
||||
|
||||
class AuthenticationTable extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get cookie => text().map(EncryptedTextConverter())();
|
||||
TextColumn get accessToken => text().map(EncryptedTextConverter())();
|
||||
DateTimeColumn get expiration => dateTime()();
|
||||
TextColumn get spotifyCookie => text().map(EncryptedTextConverter())();
|
||||
TextColumn get spotifyAccessToken => text().map(EncryptedTextConverter())();
|
||||
DateTimeColumn get spotifyExpiration => dateTime()();
|
||||
TextColumn get neteaseCookie =>
|
||||
text().map(EncryptedTextConverter()).nullable()();
|
||||
DateTimeColumn get neteaseExpiration => dateTime().nullable()();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ enum CloseBehavior {
|
||||
|
||||
enum AudioSource {
|
||||
youtube,
|
||||
piped;
|
||||
piped,
|
||||
netease,
|
||||
kugou;
|
||||
|
||||
String get label => name[0].toUpperCase() + name.substring(1);
|
||||
}
|
||||
@@ -45,8 +47,6 @@ class PreferencesTable extends Table {
|
||||
.withDefault(Constant(SourceQualities.high.name))();
|
||||
BoolColumn get albumColorSync =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get amoledDarkTheme =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get normalizeAudio =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
@@ -76,6 +76,8 @@ class PreferencesTable extends Table {
|
||||
text().withDefault(const Constant('')).map(const StringListConverter())();
|
||||
TextColumn get pipedInstance =>
|
||||
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 =>
|
||||
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
||||
TextColumn get audioSource =>
|
||||
@@ -84,12 +86,12 @@ class PreferencesTable extends Table {
|
||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
|
||||
TextColumn get downloadMusicCodec =>
|
||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
|
||||
BoolColumn get discordPresence =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get endlessPlayback =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get enableConnect =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get playerWakelock =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get overrideCacheProvider =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
|
||||
// Default values as PreferencesTableData
|
||||
static PreferencesTableData defaults() {
|
||||
@@ -97,7 +99,6 @@ class PreferencesTable extends Table {
|
||||
id: 0,
|
||||
audioQuality: SourceQualities.high,
|
||||
albumColorSync: true,
|
||||
amoledDarkTheme: false,
|
||||
checkUpdate: true,
|
||||
normalizeAudio: false,
|
||||
showSystemTrayIcon: false,
|
||||
@@ -111,14 +112,15 @@ class PreferencesTable extends Table {
|
||||
searchMode: SearchMode.youtube,
|
||||
downloadLocation: '',
|
||||
localLibraryLocation: [],
|
||||
neteaseApiInstance: 'https://rhythmbox-netease-music-api.vercel.app',
|
||||
pipedInstance: 'https://pipedapi.kavin.rocks',
|
||||
themeMode: ThemeMode.system,
|
||||
audioSource: AudioSource.youtube,
|
||||
streamMusicCodec: SourceCodecs.weba,
|
||||
downloadMusicCodec: SourceCodecs.m4a,
|
||||
discordPresence: true,
|
||||
endlessPlayback: true,
|
||||
enableConnect: false,
|
||||
playerWakelock: true,
|
||||
overrideCacheProvider: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ part of '../database.dart';
|
||||
|
||||
enum SourceType {
|
||||
youtube._('YouTube'),
|
||||
youtubeMusic._('YouTube Music');
|
||||
youtubeMusic._('YouTube Music'),
|
||||
netease._('Netease Music'),
|
||||
kugou._('Kugou Music');
|
||||
|
||||
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:drift/drift.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:lrc/lrc.dart';
|
||||
import 'package:package_info_plus/package_info_plus.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/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/lyrics/model.dart';
|
||||
@@ -164,9 +163,15 @@ class SyncedLyricsProvider extends GetxController {
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
} catch (e, stackTrace) {
|
||||
log('[Lyrics] Error: $e; Trace:\n$stackTrace');
|
||||
rethrow;
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>().logError('[Lyrics] Error: $e', trace: stack);
|
||||
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 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:flutter/foundation.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/local_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
@@ -85,7 +86,7 @@ abstract class AudioPlayerInterface {
|
||||
),
|
||||
) {
|
||||
_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: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/sourced_track/models/source_info.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 {
|
||||
Rx<SourcedTrack?> state = Rx(null);
|
||||
@@ -17,23 +19,33 @@ class ActiveSourcedTrackProvider extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> swapSibling(SourceInfo sibling) async {
|
||||
if (state.value == null) return;
|
||||
await populateSibling();
|
||||
final newTrack = await state.value!.swapWithSibling(sibling);
|
||||
if (newTrack == null) return;
|
||||
final query = Get.find<QueryingTrackInfoProvider>();
|
||||
query.isQueryingTrackInfo.value = true;
|
||||
|
||||
state.value = newTrack;
|
||||
await audioPlayer.pause();
|
||||
try {
|
||||
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>();
|
||||
final oldActiveIndex = audioPlayer.currentIndex;
|
||||
state.value = newTrack;
|
||||
|
||||
await playback.addTracksAtFirst([newTrack]);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
await playback.jumpToTrack(newTrack);
|
||||
final playback = Get.find<AudioPlayerProvider>();
|
||||
final oldActiveIndex = audioPlayer.currentIndex;
|
||||
|
||||
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,16 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart' hide Response;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart' hide Response;
|
||||
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/server/active_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';
|
||||
|
||||
class ServerPlaybackRoutesProvider {
|
||||
@@ -21,19 +24,46 @@ class ServerPlaybackRoutesProvider {
|
||||
|
||||
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
|
||||
final sourcedTrack = activeSourcedTrack.state.value?.id == track.id
|
||||
? activeSourcedTrack
|
||||
? activeSourcedTrack.state.value
|
||||
: 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 urls = jsonDecode(resp.body)['url'];
|
||||
if (urls?.isEmpty ?? true) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
'[PlaybackServer] Unable get audio source via Kugou, probably cause by paid needed resources.',
|
||||
);
|
||||
return Response(
|
||||
HttpStatus.notFound,
|
||||
body: 'Unable get audio source via Kugou',
|
||||
);
|
||||
}
|
||||
final realUrl = KugouSourcedTrack.unescapeUrl(urls.first);
|
||||
url = realUrl;
|
||||
}
|
||||
|
||||
final res = await Dio().get(
|
||||
sourcedTrack!.url,
|
||||
url,
|
||||
options: Options(
|
||||
headers: {
|
||||
...request.headers,
|
||||
'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',
|
||||
'host': Uri.parse(sourcedTrack.url).host,
|
||||
'host': Uri.parse(url).host,
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
@@ -58,8 +88,9 @@ class ServerPlaybackRoutesProvider {
|
||||
},
|
||||
headers: res.headers.map,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
log('[PlaybackSever] Error: $e; Trace:\n $stackTrace');
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[PlaybackSever] Error: $e', trace: stack);
|
||||
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 nativeAppUriDesktop => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SongLink to a JSON map.
|
||||
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 =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
@@ -63,6 +67,8 @@ class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
|
||||
// ignore: unused_field
|
||||
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')
|
||||
@override
|
||||
$Res call({
|
||||
@@ -145,6 +151,8 @@ class __$$SongLinkImplCopyWithImpl<$Res>
|
||||
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
@@ -261,12 +269,14 @@ class _$SongLinkImpl implements _SongLink {
|
||||
other.nativeAppUriDesktop == nativeAppUriDesktop));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
|
||||
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
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||
@@ -313,8 +323,11 @@ abstract class _SongLink implements SongLink {
|
||||
String? get nativeAppUriMobile;
|
||||
@override
|
||||
String? get nativeAppUriDesktop;
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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/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/server/active_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/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@@ -55,6 +61,12 @@ abstract class SourcedTrack extends Track {
|
||||
.cast<SourceInfo>();
|
||||
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease => NeteaseSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
AudioSource.piped => PipedSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
@@ -70,6 +82,33 @@ abstract class SourcedTrack extends Track {
|
||||
};
|
||||
}
|
||||
|
||||
static Future<SourcedTrack?> reRoutineFetchFromTrack(
|
||||
Track track, SourceMatchTableData cachedSource) {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final ytOrPiped = preferences.audioSource == AudioSource.piped
|
||||
? PipedSourcedTrack.fetchFromTrack
|
||||
: YoutubeSourcedTrack.fetchFromTrack;
|
||||
final sourceInfoTrackMap = {
|
||||
SourceType.youtube: ytOrPiped,
|
||||
SourceType.youtubeMusic: ytOrPiped,
|
||||
SourceType.netease: NeteaseSourcedTrack.fetchFromTrack,
|
||||
SourceType.kugou: KugouSourcedTrack.fetchFromTrack,
|
||||
};
|
||||
return sourceInfoTrackMap[cachedSource.sourceType]!(track: track);
|
||||
}
|
||||
|
||||
Future<SourcedTrack?> reRoutineSwapSiblings(SourceInfo info) {
|
||||
final sourceInfoTrackMap = {
|
||||
YoutubeSourceInfo: YoutubeSourcedTrack.fetchFromTrack,
|
||||
PipedSourceInfo: PipedSourcedTrack.fetchFromTrack,
|
||||
NeteaseSourceInfo: NeteaseSourcedTrack.fetchFromTrack,
|
||||
KugouSourceInfo: KugouSourcedTrack.fetchFromTrack,
|
||||
};
|
||||
return sourceInfoTrackMap[info.runtimeType]!(
|
||||
track: Get.find<ActiveSourcedTrackProvider>().state.value!,
|
||||
);
|
||||
}
|
||||
|
||||
static String getSearchTerm(Track track) {
|
||||
final artists = (track.artists ?? [])
|
||||
.map((ar) => ar.name)
|
||||
@@ -88,20 +127,73 @@ abstract class SourcedTrack extends Track {
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
AudioSource? fallbackTo,
|
||||
}) async {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final audioSource = preferences.audioSource;
|
||||
var audioSource = preferences.audioSource;
|
||||
|
||||
if (!preferences.overrideCacheProvider && fallbackTo == null) {
|
||||
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);
|
||||
|
||||
final ytOrPiped = preferences.audioSource == AudioSource.youtube
|
||||
? AudioSource.youtube
|
||||
: AudioSource.piped;
|
||||
final sourceTypeTrackMap = {
|
||||
SourceType.youtube: ytOrPiped,
|
||||
SourceType.youtubeMusic: ytOrPiped,
|
||||
SourceType.netease: AudioSource.netease,
|
||||
SourceType.kugou: AudioSource.kugou,
|
||||
};
|
||||
|
||||
if (cachedSource != null) {
|
||||
final cachedAudioSource = sourceTypeTrackMap[cachedSource.sourceType]!;
|
||||
audioSource = cachedAudioSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackTo != null) {
|
||||
audioSource = fallbackTo;
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease =>
|
||||
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.kugou =>
|
||||
await KugouSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.piped =>
|
||||
await PipedSourcedTrack.fetchFromTrack(track: track),
|
||||
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||
};
|
||||
} on TrackNotFoundError catch (_) {
|
||||
// TODO Try to look it up in other source
|
||||
// But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary
|
||||
rethrow;
|
||||
} on TrackNotFoundError catch (err) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
'${err.toString()} via ${preferences.audioSource.label}, querying in fallback sources...',
|
||||
);
|
||||
|
||||
if (fallbackTo != null) {
|
||||
// Prevent infinite fallback
|
||||
if (audioSource == AudioSource.youtube ||
|
||||
audioSource == AudioSource.piped) rethrow;
|
||||
}
|
||||
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
|
||||
AudioSource.kugou =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
|
||||
_ =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.netease),
|
||||
};
|
||||
} on HttpClientClosedException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||
} on VideoUnplayableException catch (_) {
|
||||
|
||||
242
lib/services/sourced_track/sources/kugou.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
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<SourcedTrack> 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),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return KugouSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.kugou) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
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 is KugouSourceInfo
|
||||
? manifest.id
|
||||
: 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.where((x) => x['pay_type'] == 0).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<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! KugouSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
283
lib/services/sourced_track/sources/netease.dart
Executable file
@@ -0,0 +1,283 @@
|
||||
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<SourcedTrack> 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),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.netease) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
final client = getClient();
|
||||
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
|
||||
if (resp.body?['songs'] == null) throw TrackNotFoundError(track);
|
||||
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=${await 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<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! NeteaseSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,14 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
|
||||
if (cachedSource?.sourceType != SourceType.youtube &&
|
||||
cachedSource?.sourceType != SourceType.youtubeMusic) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource!);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cachedSource == null) {
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
@@ -73,6 +81,7 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
: SourceType.youtubeMusic,
|
||||
),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
@@ -255,6 +264,10 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! PipedSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:http/http.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/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
@@ -44,7 +43,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static Future<YoutubeSourcedTrack> fetchFromTrack({
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
final DatabaseProvider db = Get.find();
|
||||
@@ -70,6 +69,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
sourceId: siblings.first.info.id,
|
||||
sourceType: const Value(SourceType.youtube),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
@@ -78,6 +78,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.youtube) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
final item = await youtubeClient.videos.get(cachedSource.sourceId);
|
||||
@@ -86,7 +91,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
cachedSource.sourceId,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
return YoutubeSourcedTrack(
|
||||
@@ -141,7 +146,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
if (index == 0) {
|
||||
final manifest =
|
||||
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
@@ -242,14 +247,15 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
];
|
||||
} on VideoUnplayableException catch (e) {
|
||||
// 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 searchResults = await youtubeClient.search.search(
|
||||
'$query - Topic',
|
||||
query,
|
||||
filter: TypeFilters.video,
|
||||
);
|
||||
|
||||
@@ -268,7 +274,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! YoutubeSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -285,7 +295,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
final manifest = await youtubeClient.videos.streamsClient
|
||||
.getManifest(newSourceInfo.id)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
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);
|
||||
|
||||
await windowManager.waitUntilReadyToShow(
|
||||
const WindowOptions(
|
||||
WindowOptions(
|
||||
title: 'RhythmBox',
|
||||
backgroundColor: Colors.transparent,
|
||||
minimumSize: Size(300, 700),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
minimumSize: const Size(300, 700),
|
||||
titleBarStyle:
|
||||
PlatformInfo.isMacOS ? TitleBarStyle.hidden : TitleBarStyle.normal,
|
||||
center: true,
|
||||
),
|
||||
() async {
|
||||
|
||||
@@ -24,6 +24,7 @@ class _NavShellState extends State<NavShell> {
|
||||
|
||||
final List<Destination> _allDestinations = <Destination>[
|
||||
Destination('explore'.tr, 'explore', Icons.explore),
|
||||
Destination('library'.tr, 'library', Icons.video_library),
|
||||
Destination('search'.tr, 'search', Icons.search),
|
||||
Destination('settings'.tr, 'settings', Icons.settings),
|
||||
];
|
||||
@@ -40,6 +41,8 @@ class _NavShellState extends State<NavShell> {
|
||||
const BottomPlayer(key: Key('app-wide-bottom-player')),
|
||||
const Divider(height: 0.3, thickness: 0.3),
|
||||
BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
|
||||
elevation: 0,
|
||||
showUnselectedLabels: false,
|
||||
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 = {
|
||||
'appName': 'RhythmBox',
|
||||
'explore': 'Explore',
|
||||
'library': 'Library',
|
||||
'settings': 'Settings',
|
||||
'search': 'Search',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const i18nSimplifiedChinese = {
|
||||
'appName': '韵律盒',
|
||||
'explore': '探索',
|
||||
'library': '资料库',
|
||||
'settings': '设置',
|
||||
'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 {
|
||||
final String url;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -15,12 +17,14 @@ class AutoCacheImage extends StatelessWidget {
|
||||
imageUrl: url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:gap/gap.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/services/lyrics/model.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';
|
||||
|
||||
class SyncedLyrics extends StatefulWidget {
|
||||
@@ -28,15 +30,16 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
final AutoScrollController _autoScrollController = AutoScrollController();
|
||||
|
||||
late final int _textZoomLevel = widget.defaultTextZoom;
|
||||
late Duration _durationCurrent = audioPlayer.position;
|
||||
|
||||
SubtitleSimple? _lyric;
|
||||
String? _activeTrackId;
|
||||
|
||||
bool get _isLyricSynced =>
|
||||
_lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0);
|
||||
|
||||
Future<void> _pullLyrics() async {
|
||||
if (_playback.state.value.activeTrack == null) return;
|
||||
_activeTrackId = _playback.state.value.activeTrack!.id;
|
||||
final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!);
|
||||
setState(() => _lyric = out);
|
||||
}
|
||||
@@ -44,16 +47,53 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
List<StreamSubscription>? _subscriptions;
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pullLyrics().then((_) {
|
||||
_syncLyricsProgress();
|
||||
});
|
||||
_subscriptions = [
|
||||
audioPlayer.positionStream
|
||||
.listen((dur) => setState(() => _durationCurrent = dur)),
|
||||
_playback.state.listen((value) {
|
||||
if (value.activeTrack == null) return;
|
||||
if (value.activeTrack!.id != _activeTrackId) {
|
||||
_pullLyrics().then((_) {
|
||||
_syncLyricsProgress();
|
||||
});
|
||||
}
|
||||
}),
|
||||
];
|
||||
_pullLyrics();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -72,29 +112,52 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return CustomScrollView(
|
||||
cacheExtent: 10000,
|
||||
controller: _autoScrollController,
|
||||
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)
|
||||
SliverList.builder(
|
||||
itemCount: _lyric!.lyrics.length,
|
||||
itemBuilder: (context, idx) {
|
||||
itemBuilder: (context, idx) => Obx(() {
|
||||
final lyricSlice = _lyric!.lyrics[idx];
|
||||
final lyricNextSlice = idx + 1 < _lyric!.lyrics.length
|
||||
? _lyric!.lyrics[idx + 1]
|
||||
: null;
|
||||
final isActive =
|
||||
_durationCurrent.inSeconds >= lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_durationCurrent.inSeconds);
|
||||
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||
lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_playback.durationCurrent.value.inSeconds) &&
|
||||
_isLyricSynced;
|
||||
|
||||
if (_durationCurrent.inSeconds == lyricSlice.time.inSeconds &&
|
||||
if (_playback.durationCurrent.value.inSeconds ==
|
||||
lyricSlice.time.inSeconds &&
|
||||
_isLyricSynced) {
|
||||
_autoScrollController.scrollToIndex(
|
||||
idx,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
}
|
||||
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(idx),
|
||||
index: idx,
|
||||
@@ -107,8 +170,9 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
)
|
||||
: Padding(
|
||||
padding: idx == _lyric!.lyrics.length - 1
|
||||
? const EdgeInsets.all(8.0).copyWith(bottom: 100)
|
||||
: const EdgeInsets.all(8.0),
|
||||
? const EdgeInsets.symmetric(vertical: 8)
|
||||
.copyWith(bottom: 80)
|
||||
: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
style: TextStyle(
|
||||
@@ -119,6 +183,9 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
onTap: () async {
|
||||
final time = Duration(
|
||||
seconds: lyricSlice.time.inSeconds -
|
||||
@@ -131,26 +198,51 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
audioPlayer.seek(time);
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
return Text(
|
||||
lyricSlice.text,
|
||||
return AnimatedDefaultTextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: isActive ? 20 : 16,
|
||||
color: isActive
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: _unFocusColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
).animate(target: isActive ? 1 : 0).scale(
|
||||
duration: 300.ms,
|
||||
begin: const Offset(0.9, 0.9),
|
||||
end: const Offset(1.3, 1.3),
|
||||
);
|
||||
}).paddingSymmetric(horizontal: 12),
|
||||
duration: 500.ms,
|
||||
curve: Curves.decelerate,
|
||||
child: Text(
|
||||
lyricSlice.text,
|
||||
textAlign:
|
||||
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:get/get.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/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_services/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/tracks/querying_track_info.dart';
|
||||
import 'package:rhythm_box/widgets/volume_slider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class BottomPlayer extends StatefulWidget {
|
||||
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
|
||||
State<BottomPlayer> createState() => _BottomPlayerState();
|
||||
@@ -35,38 +46,22 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
late final QueryingTrackInfoProvider _query = Get.find();
|
||||
|
||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||
|
||||
String? get _albumArt =>
|
||||
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
||||
index:
|
||||
(_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;
|
||||
|
||||
Future<void> _togglePlayState() async {
|
||||
if (!audioPlayer.isPlaying) {
|
||||
await audioPlayer.resume();
|
||||
} else {
|
||||
await audioPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLifted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscriptions = [
|
||||
audioPlayer.durationStream
|
||||
.listen((dur) => setState(() => _durationTotal = dur)),
|
||||
audioPlayer.positionStream
|
||||
.listen((dur) => setState(() => _durationCurrent = dur)),
|
||||
_playback.state.listen((state) {
|
||||
if (state.playlist.medias.isNotEmpty && !_isLifted) {
|
||||
_animationController.animateTo(1);
|
||||
@@ -109,78 +104,125 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_durationCurrent != Duration.zero)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: _durationCurrent.inMilliseconds /
|
||||
max(_durationTotal.inMilliseconds, 1),
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
value: value,
|
||||
),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: _playback.durationCurrent.value.inMilliseconds /
|
||||
max(_playback.durationTotal.value.inMilliseconds, 1),
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
value: _isFetchingActiveTrack ? null : value,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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,
|
||||
child: Row(
|
||||
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),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToNext,
|
||||
),
|
||||
IconButton.filled(
|
||||
icon: _isFetchingActiveTrack
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
||||
if (MediaQuery.of(context).size.width >= 720)
|
||||
const Expanded(child: PlayerControls())
|
||||
else
|
||||
const PlayerControls(),
|
||||
if (MediaQuery.of(context).size.width >= 720) const Gap(12),
|
||||
if (MediaQuery.of(context).size.width >= 720)
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.speaker, size: 18),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => const PlayerDevicePopup(),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!widget.isMiniPlayer && PlatformInfo.isDesktop)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.picture_in_picture,
|
||||
size: 18,
|
||||
),
|
||||
onPressed:
|
||||
_isFetchingActiveTrack ? null : _togglePlayState,
|
||||
onPressed: () async {
|
||||
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),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.onTap != null) {
|
||||
widget.onTap!();
|
||||
return;
|
||||
}
|
||||
if (widget.usePop) {
|
||||
GoRouter.of(context).pop();
|
||||
} 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||