61 Commits

Author SHA1 Message Date
a6b40e81a7 🐛 Bug fixes on looking up ips 2024-09-11 20:37:42 +08:00
1913a7e909 🐛 Bug fixes on cross source track fetching 2024-09-11 20:18:03 +08:00
d860936010 🚀 Launch 1.0.0+17 2024-09-10 23:45:50 +08:00
873ad1cf8c 🐛 Fix cross source swap siblings issue 2024-09-10 23:06:15 +08:00
e0c9edad78 💄 Downgrade kugou source 2024-09-08 22:24:10 +08:00
70ea02962f 📝 Update README.md 2024-09-08 01:27:44 +08:00
59783c48f7 🐛 Fix & optimize kugou audio source
🐛 Fix fallback source switch causing error
2024-09-08 01:20:46 +08:00
b099f63f61 🐛 Fix settings page issue 2024-09-07 19:57:26 +08:00
b69bee7e59 🚀 Launch 1.0.0+13 2024-09-07 18:58:33 +08:00
3d23152802 💄 Auto dismiss error 2024-09-07 18:54:01 +08:00
90dc3f43a7 Allow player keep original cache provider
 Support chain fallback
2024-09-07 18:49:05 +08:00
3df93e47d2 🚀 Launch 1.2.1+12 2024-09-06 22:44:39 +08:00
6d2a027d9b Kugou music source 2024-09-06 22:19:26 +08:00
222d50d80d 🐛 Bug fixes of querying backend 2024-09-06 18:10:12 +08:00
499bca5b1c Better netease music check 2024-09-06 16:37:49 +08:00
252e4619f7 🐛 Fix player view layout issue 2024-09-06 16:22:39 +08:00
463cb9870f 🐛 Fixes 2024-09-06 13:26:20 +08:00
2cee7ee958 🐛 Search siblings in netease won't show duration 2024-09-06 12:55:10 +08:00
e30e7a5c24 🐛 Fix source track details 2024-09-06 12:52:57 +08:00
6509cd2511 Netease cloud music login 2024-09-06 12:41:35 +08:00
6cdc025c40 🐛 Will throw track not found when has no privilege to play on netease cloud music 2024-09-05 21:53:42 +08:00
de3ad4b21e 🐛 Bug fixes 2024-09-05 13:14:02 +08:00
ad1c188982 🐛 Fix settings page overflow 2024-09-05 00:04:09 +08:00
43fae51462 Track details 2024-09-05 00:01:58 +08:00
9012f560b5 🐛 Fix netease switch sibling tracks issue 2024-09-04 23:40:47 +08:00
19a7fd82df Netease backend support 2024-09-04 23:28:59 +08:00
010ee6286f 🚚 Rename macos package 2024-09-03 00:03:32 +08:00
3c3447a9ee 🐛 Fix wakelock doesn't work 2024-09-02 23:52:38 +08:00
ee2633db52 Error notifier 2024-09-02 21:20:30 +08:00
ddeda2ce23 💄 Player optimization 2024-09-02 20:42:33 +08:00
a5f39321eb Player wakelock 2024-09-02 20:25:19 +08:00
da2a3508d1 🐛 Bug fixes on wm tools 2024-09-01 17:53:44 +08:00
ed7b69f7b3 🐛 Fix windows title bar issue 2024-09-01 17:36:21 +08:00
710ab755fc 🐛 Fix macos signing issue 2024-08-30 23:40:29 +08:00
4fd9447591 💄 Optimize UX 2024-08-30 23:18:55 +08:00
c97a7ae859 iOS background playing 2024-08-30 22:57:38 +08:00
4bf8715486 🐛 Fix timeout 2024-08-30 22:16:10 +08:00
fbb12ff801 🐛 Bug fixes 2024-08-30 22:06:24 +08:00
47d051dd44 🐛 Bug fixes 2024-08-30 21:53:40 +08:00
1ac7704080 🐛 Fix bugs on windows 2024-08-30 19:53:53 +08:00
b7b673c96d 🚀 Launch v1.0.0+1
Some checks failed
release-nightly / build-web (push) Has been cancelled
release-nightly / build-exe (push) Has been cancelled
2024-08-30 15:06:12 +08:00
f772bbdbbc :rocket Ready to launch! 2024-08-30 14:05:11 +08:00
a95292a9ef Better explore ever 2024-08-30 13:43:29 +08:00
07a86c32a0 🐛 Bug fixes 2024-08-30 13:23:57 +08:00
f16c216479 🍱 Update icons 2024-08-30 13:18:36 +08:00
8b8915e28f Mini player 2024-08-30 12:56:28 +08:00
0a24c86682 🚚 Update package name & label 2024-08-30 02:01:08 +08:00
d25ebbf6bd 🐛 Bug fixes 2024-08-30 01:56:27 +08:00
be977f10d1 Better explore 2024-08-30 01:38:02 +08:00
bb09c43135 Volume slider 2024-08-30 00:28:12 +08:00
989440013c 🐛 Fix put order issue 2024-08-29 23:07:49 +08:00
d80a398a23 Endless playback 2024-08-29 23:03:41 +08:00
3ca01ef147 🐛 Bug fixes and optimization 2024-08-29 22:39:54 +08:00
586f47575c Audio normalize 2024-08-29 22:34:56 +08:00
ef40c2ffe4 💫 Optimize lyrics 2024-08-29 19:10:54 +08:00
7e95c167ef :sparklesS: Able to search siblings tracks 2024-08-29 17:55:35 +08:00
a063d19952 User library 2024-08-29 16:42:48 +08:00
7285eb4959 Connect with spotify 2024-08-29 15:02:49 +08:00
be44aadc07 📱 Large screen support 2024-08-29 01:45:33 +08:00
249c8fbf80 ♻️ Improve progress display 2024-08-29 00:41:40 +08:00
2134500089 Alternative tracks 2024-08-29 00:33:59 +08:00
156 changed files with 9140 additions and 2107 deletions

24
.github/workflows/nightly.yml vendored Normal file
View 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

View File

@@ -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.

View File

@@ -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" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

View 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>

View File

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

View 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>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

View File

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

View File

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

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 339 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View 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;
});
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
});
}

View 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);
}

View 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&timestamp=${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&timestamp=${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'),
)
],
),
),
);
}
}

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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() {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (_) {

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

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

View File

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

View File

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

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

View 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>>()
};
}

File diff suppressed because it is too large Load Diff

View 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
View 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();
}
}

View File

@@ -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 {

View File

@@ -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,

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

View File

@@ -1,6 +1,7 @@
const i18nEnglish = {
'appName': 'RhythmBox',
'explore': 'Explore',
'library': 'Library',
'settings': 'Settings',
'search': 'Search',
};

View File

@@ -1,6 +1,7 @@
const i18nSimplifiedChinese = {
'appName': '韵律盒',
'explore': '探索',
'library': '资料库',
'settings': '设置',
'search': '搜索',
};

View 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!();
},
),
);
}
}

View File

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

View File

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

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

View File

@@ -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 {

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

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

Some files were not shown because too many files have changed in this diff Show More