Compare commits
25 Commits
3f41573f00
...
1.0.0+3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf8715486 | |||
| fbb12ff801 | |||
| 47d051dd44 | |||
| 1ac7704080 | |||
| b7b673c96d | |||
| f772bbdbbc | |||
| a95292a9ef | |||
| 07a86c32a0 | |||
| f16c216479 | |||
| 8b8915e28f | |||
| 0a24c86682 | |||
| d25ebbf6bd | |||
| be977f10d1 | |||
| bb09c43135 | |||
| 989440013c | |||
| d80a398a23 | |||
| 3ca01ef147 | |||
| 586f47575c | |||
| ef40c2ffe4 | |||
| 7e95c167ef | |||
| a063d19952 | |||
| 7285eb4959 | |||
| be44aadc07 | |||
| 249c8fbf80 | |||
| 2134500089 |
24
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: release-nightly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
build-exe:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build windows
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output-windows
|
||||
path: build/windows/x64/runner/Release
|
||||
BIN
android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
BIN
android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
19
android/app/src/main/res/values-night-v31/styles.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
19
android/app/src/main/res/values-v31/styles.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -5,6 +5,10 @@
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
BIN
assets/icon-w-shadow.png
Normal file
|
After Width: | Height: | Size: 553 KiB |
@@ -8,10 +8,24 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_broadcasts (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_inappwebview_ios/Core (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_native_splash (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- media_kit_libs_ios_audio (1.0.4):
|
||||
- Flutter
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- Flutter
|
||||
- metadata_god (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (5.0.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -23,6 +37,26 @@ 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
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||
@@ -30,12 +64,23 @@ 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`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- OrderedSet
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
@@ -48,10 +93,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 +113,10 @@ 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"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
@@ -67,12 +124,20 @@ 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
|
||||
|
||||
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
|
||||
|
||||
|
||||
@@ -161,7 +161,6 @@
|
||||
1CF40EE9C145DC3FDC6C41BF /* Pods-RunnerTests.release.xcconfig */,
|
||||
DAFDCBCA918FE99EC399DF6B /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -474,11 +473,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -495,7 +496,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -513,7 +514,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -529,7 +530,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -657,11 +658,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -680,11 +683,13 @@
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
22
ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "darkbackground.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png
vendored
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 339 KiB |
@@ -16,13 +16,19 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
@@ -32,6 +38,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="2050" height="2048"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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 +13,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>
|
||||
@@ -45,5 +45,11 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>To provide information for RhythmBox to normalize the output audio</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,32 +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/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/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';
|
||||
import 'package:window_manager/window_manager.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 windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
if (PlatformInfo.isMacOS) {
|
||||
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
|
||||
} else {
|
||||
await windowManager.setTitleBarStyle(TitleBarStyle.normal);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -54,6 +89,13 @@ class MyApp extends StatelessWidget {
|
||||
themeMode: ThemeMode.system,
|
||||
translations: AppTranslations(),
|
||||
onInit: () => _initializeProviders(context),
|
||||
builder: (context, child) {
|
||||
return SystemShell(
|
||||
child: ScaffoldMessenger(
|
||||
child: child ?? const SizedBox(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +104,7 @@ class MyApp extends StatelessWidget {
|
||||
Get.lazyPut(() => SyncedLyricsProvider());
|
||||
|
||||
Get.put(DatabaseProvider());
|
||||
Get.put(AuthenticationProvider());
|
||||
|
||||
Get.put(AudioPlayerProvider());
|
||||
Get.put(ActiveSourcedTrackProvider());
|
||||
@@ -75,6 +118,9 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
Get.put(QueryingTrackInfoProvider());
|
||||
Get.put(SourcedTrackProvider());
|
||||
Get.put(EndlessPlaybackProvider());
|
||||
Get.put(VolumeProvider());
|
||||
Get.put(RecentlyPlayedProvider());
|
||||
|
||||
Get.put(ServerPlaybackRoutesProvider());
|
||||
Get.put(PlaybackServerProvider());
|
||||
|
||||
@@ -7,10 +7,17 @@ import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/services/audio_player/state.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/local_track.dart';
|
||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
import 'package:spotify/spotify.dart' hide Playlist;
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
|
||||
class AudioPlayerProvider extends GetxController {
|
||||
Rx<Duration> durationTotal = Rx(Duration.zero);
|
||||
Rx<Duration> durationCurrent = Rx(Duration.zero);
|
||||
Rx<Duration> durationBuffered = Rx(Duration.zero);
|
||||
|
||||
RxBool isPlaying = false.obs;
|
||||
|
||||
Rx<AudioPlayerState> state = Rx(AudioPlayerState(
|
||||
@@ -54,6 +61,11 @@ class AudioPlayerProvider extends GetxController {
|
||||
state.value = state.value.copyWith(playlist: playlist);
|
||||
await _updatePlaylist(playlist);
|
||||
}),
|
||||
audioPlayer.durationStream.listen((value) => durationTotal.value = value),
|
||||
audioPlayer.positionStream
|
||||
.listen((value) => durationCurrent.value = value),
|
||||
audioPlayer.bufferedPositionStream
|
||||
.listen((value) => durationBuffered.value = value),
|
||||
];
|
||||
|
||||
_readSavedState();
|
||||
@@ -239,11 +251,12 @@ class AudioPlayerProvider extends GetxController {
|
||||
|
||||
// Giving the initial track a boost so MediaKit won't skip
|
||||
// because of timeout
|
||||
// final intendedActiveTrack = medias.elementAt(initialIndex);
|
||||
// if (intendedActiveTrack.track is! LocalTrack) {
|
||||
// await Get.find<SourcedTrackProvider>()
|
||||
// .fetch(RhythmMedia(intendedActiveTrack.track));
|
||||
// }
|
||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||
final intendedActiveTrack = medias.elementAt(initialIndex);
|
||||
if (intendedActiveTrack.track is! LocalTrack) {
|
||||
await Get.find<SourcedTrackProvider>()
|
||||
.fetch(RhythmMedia(intendedActiveTrack.track));
|
||||
}
|
||||
|
||||
if (medias.isEmpty) return;
|
||||
|
||||
|
||||
140
lib/providers/auth.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
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(expiration);
|
||||
|
||||
String? getCookie(String key) => cookie.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) {
|
||||
refreshCredentials();
|
||||
}
|
||||
refreshTimer = Timer(
|
||||
auth.value!.expiration.difference(DateTime.now()),
|
||||
() => refreshCredentials(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refreshCredentials() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
final refreshedCredentials =
|
||||
await credentialsFromCookie(auth.value!.cookie.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),
|
||||
cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"),
|
||||
accessToken: DecryptedText(body['accessToken']),
|
||||
expiration: DateTime.fromMillisecondsSinceEpoch(
|
||||
body['accessTokenExpirationTimestampMs']),
|
||||
);
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
refreshTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
103
lib/providers/endless_playback.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
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';
|
||||
|
||||
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) {
|
||||
log('[EndlessPlayback] Error: $e; Trace:\n$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);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ class PaletteProvider extends GetxController {
|
||||
|
||||
void updatePalette(PaletteGenerator? newPalette) {
|
||||
palette.value = newPalette;
|
||||
print('call update!');
|
||||
print(newPalette);
|
||||
if (newPalette != null) {
|
||||
Get.changeTheme(
|
||||
ThemeData.from(
|
||||
|
||||
51
lib/providers/recent_played.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
|
||||
class RecentlyPlayedProvider extends GetxController {
|
||||
Future<List<HistoryTableData>> fetch() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
|
||||
final uniqueItemIds = await (database.selectOnly(
|
||||
database.historyTable,
|
||||
distinct: true,
|
||||
)
|
||||
..addColumns([database.historyTable.itemId, database.historyTable.id])
|
||||
..where(
|
||||
database.historyTable.type.isInValues([
|
||||
HistoryEntryType.playlist,
|
||||
HistoryEntryType.album,
|
||||
]),
|
||||
)
|
||||
..limit(10)
|
||||
..orderBy([
|
||||
OrderingTerm(
|
||||
expression: database.historyTable.createdAt,
|
||||
mode: OrderingMode.desc,
|
||||
),
|
||||
]))
|
||||
.map(
|
||||
(row) => row.read(database.historyTable.id),
|
||||
)
|
||||
.get()
|
||||
.then((value) => value.whereNotNull().toList());
|
||||
|
||||
final query = database.select(database.historyTable)
|
||||
..where(
|
||||
(tbl) => tbl.id.isIn(uniqueItemIds),
|
||||
)
|
||||
..orderBy([
|
||||
(tbl) => OrderingTerm(
|
||||
expression: tbl.createdAt,
|
||||
mode: OrderingMode.desc,
|
||||
),
|
||||
]);
|
||||
|
||||
final fetchedItems = await query.get();
|
||||
return fetchedItems;
|
||||
}
|
||||
}
|
||||
@@ -1,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!.accessToken.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_subscriptions != null) {
|
||||
for (final subscription in _subscriptions!) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
20
lib/providers/volume.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/kv_store/kv_store.dart';
|
||||
|
||||
class VolumeProvider extends GetxController {
|
||||
RxDouble volume = KVStoreService.volume.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
audioPlayer.setVolume(volume.value);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double newVolume) async {
|
||||
volume.value = newVolume;
|
||||
await audioPlayer.setVolume(newVolume);
|
||||
KVStoreService.setVolume(newVolume);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
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/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 +23,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 +40,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 +84,26 @@ 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
93
lib/screens/about.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const denseButtonStyle =
|
||||
ButtonStyle(visualDensity: VisualDensity(vertical: -4));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('About'),
|
||||
),
|
||||
body: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Image.asset('assets/icon.png', width: 120, height: 120),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
PlatformInfo.isIOS || PlatformInfo.isMacOS
|
||||
? 'GroovyBox'
|
||||
: 'RhythmBox',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text(
|
||||
'Yet another Spotify third-party app',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Text(
|
||||
'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: const Text('App Details'),
|
||||
onPressed: () async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationVersion: '${info.version} (${info.buildNumber})',
|
||||
applicationLegalese: 'Yet another Spotify third-party app.',
|
||||
applicationIcon: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child:
|
||||
Image.asset('assets/icon.png', width: 60, height: 60),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: const Text('Project Website'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://solsynth.dev/products/rhythm-box');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Open-sourced under AGPLv3',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
229
lib/screens/album/view.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/history.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/track.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class AlbumViewScreen extends StatefulWidget {
|
||||
final String albumId;
|
||||
final Playlist? playlist;
|
||||
|
||||
const AlbumViewScreen({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
this.playlist,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AlbumViewScreen> createState() => _AlbumViewScreenState();
|
||||
}
|
||||
|
||||
class _AlbumViewScreenState extends State<AlbumViewScreen> {
|
||||
late final SpotifyProvider _spotify = Get.find();
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
|
||||
bool get _isCurrentPlaylist => _album != null
|
||||
? _playback.state.value.containsCollection(_album!.id!)
|
||||
: false;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingTracks = true;
|
||||
bool _isUpdating = false;
|
||||
|
||||
Album? _album;
|
||||
List<Track>? _tracks;
|
||||
|
||||
Future<void> _pullPlaylist() async {
|
||||
_album = await _spotify.api.albums.get(widget.albumId);
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _pullTracks() async {
|
||||
_tracks = (await _spotify.api.albums.tracks(widget.albumId).all())
|
||||
.map((x) => x.asTrack(_album!))
|
||||
.toList();
|
||||
setState(() => _isLoadingTracks = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pullPlaylist();
|
||||
_pullTracks();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const radius = BorderRadius.all(Radius.circular(8));
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Playlist'),
|
||||
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return CenteredContainer(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Material(
|
||||
borderRadius: radius,
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: (_album?.images?.isNotEmpty ?? false)
|
||||
? AutoCacheImage(
|
||||
_album!.images!.first.url!,
|
||||
width: 160.0,
|
||||
height: 160.0,
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 160,
|
||||
height: 160,
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_album!.name ?? 'Playlist',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
Text(
|
||||
_album!.artists?.asString() ?? 'A Playlist',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_album!.popularity ?? 0)} views",
|
||||
),
|
||||
Text(
|
||||
'#${_album!.id}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: 24, right: 24, top: 24),
|
||||
const Gap(8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Obx(
|
||||
() => ElevatedButton.icon(
|
||||
icon: (_isCurrentPlaylist &&
|
||||
_playback.isPlaying.value)
|
||||
? const Icon(Icons.pause_outlined)
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
onPressed: _isUpdating
|
||||
? null
|
||||
: () async {
|
||||
if (_isCurrentPlaylist &&
|
||||
_playback.isPlaying.value) {
|
||||
audioPlayer.pause();
|
||||
return;
|
||||
} else if (_isCurrentPlaylist &&
|
||||
!_playback.isPlaying.value) {
|
||||
audioPlayer.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isUpdating = true);
|
||||
|
||||
await _playback.load(_tracks!,
|
||||
autoPlay: true);
|
||||
_playback.addCollection(_album!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
.addAlbums([_album!]);
|
||||
|
||||
setState(() => _isUpdating = false);
|
||||
},
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
label: const Text('Shuffle'),
|
||||
onPressed: _isUpdating
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isUpdating = true);
|
||||
|
||||
audioPlayer.setShuffle(true);
|
||||
|
||||
await _playback.load(
|
||||
_tracks!,
|
||||
autoPlay: true,
|
||||
initialIndex:
|
||||
Random().nextInt(_tracks!.length),
|
||||
);
|
||||
_playback.addCollection(_album!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
.addAlbums([_album!]);
|
||||
|
||||
setState(() => _isUpdating = false);
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Songs (${_tracks?.length ?? 0})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||
),
|
||||
PlaylistTrackList(
|
||||
isLoading: _isLoadingTracks,
|
||||
playlistId: widget.albumId,
|
||||
tracks: _tracks,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/screens/auth/desktop_login.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
|
||||
Future<void> desktopLogin(BuildContext context) async {
|
||||
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
|
||||
final applicationSupportDir = await getApplicationSupportDirectory();
|
||||
final userDataFolder =
|
||||
Directory(join(applicationSupportDir.path, 'webview_window_Webview2'));
|
||||
|
||||
if (!await userDataFolder.exists()) {
|
||||
await userDataFolder.create();
|
||||
}
|
||||
|
||||
final webview = await WebviewWindow.create(
|
||||
configuration: CreateConfiguration(
|
||||
title: 'Spotify Login',
|
||||
titleBarTopPadding: PlatformInfo.isMacOS ? 20 : 0,
|
||||
windowHeight: 720,
|
||||
windowWidth: 1280,
|
||||
userDataFolderWindows: userDataFolder.path,
|
||||
),
|
||||
);
|
||||
webview
|
||||
..setBrightness(Theme.of(context).colorScheme.brightness)
|
||||
..launch('https://accounts.spotify.com/')
|
||||
..setOnUrlRequestCallback((url) {
|
||||
if (exp.hasMatch(url)) {
|
||||
webview.getAllCookies().then((cookies) async {
|
||||
final cookieHeader =
|
||||
"sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}";
|
||||
|
||||
final AuthenticationProvider authenticate = Get.find();
|
||||
await authenticate.login(cookieHeader);
|
||||
|
||||
webview.close();
|
||||
if (context.mounted) {
|
||||
GoRouter.of(context).go('/');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
13
lib/screens/auth/login.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/screens/auth/desktop_login.dart';
|
||||
|
||||
Future<void> universalLogin(BuildContext context) async {
|
||||
if (PlatformInfo.isMobile) {
|
||||
GoRouter.of(context).pushNamed('authMobileLogin');
|
||||
return;
|
||||
}
|
||||
|
||||
return await desktopLogin(context);
|
||||
}
|
||||
67
lib/screens/auth/mobile_login.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
|
||||
class MobileLogin extends StatelessWidget {
|
||||
const MobileLogin({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthenticationProvider authenticate = Get.find();
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
const Scaffold(
|
||||
body: Center(
|
||||
child: Text('This feature is not available on desktop'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect with Spotify'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: InAppWebView(
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
|
||||
),
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri('https://accounts.spotify.com/'),
|
||||
),
|
||||
onPermissionRequest: (controller, permissionRequest) async {
|
||||
return PermissionResponse(
|
||||
resources: permissionRequest.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, action) async {
|
||||
if (action == null) return;
|
||||
String url = action.toString();
|
||||
if (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
|
||||
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
|
||||
|
||||
if (exp.hasMatch(url)) {
|
||||
final cookies =
|
||||
await CookieManager.instance().getCookies(url: action);
|
||||
final cookieHeader =
|
||||
"sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}";
|
||||
|
||||
await authenticate.login(cookieHeader);
|
||||
if (context.mounted) {
|
||||
GoRouter.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/providers/recent_played.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/album.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/spotify/spotify_endpoints.dart';
|
||||
import 'package:rhythm_box/widgets/playlist/playlist_section.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class ExploreScreen extends StatefulWidget {
|
||||
@@ -15,15 +21,51 @@ 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);
|
||||
setState(() => _isLoading['featured'] = false);
|
||||
|
||||
_recentlyPlaylist = (await _history.fetch())
|
||||
.where((x) => x.playlist != null)
|
||||
.map((x) => x.playlist!)
|
||||
.toList();
|
||||
setState(() => _isLoading['recently'] = false);
|
||||
|
||||
_newReleasesPlaylist =
|
||||
(await _spotify.api.browse.newReleases(country: market).getPage(20))
|
||||
.items
|
||||
?.map((album) => album.toAlbum())
|
||||
.toList();
|
||||
setState(() => _isLoading['newReleases'] = false);
|
||||
|
||||
final customEndpoint =
|
||||
CustomSpotifyEndpoints(_auth.auth.value?.accessToken.value ?? '');
|
||||
final forYouView = await customEndpoint.getView(
|
||||
'made-for-x-hub',
|
||||
market: market,
|
||||
locale: Intl.canonicalizedLocale(locale.toString()),
|
||||
);
|
||||
_forYouView = forYouView['content']?['items'];
|
||||
setState(() => _isLoading['forYou'] = false);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -39,46 +81,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,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['newReleases']!,
|
||||
title: 'New Releases',
|
||||
list: _newReleasesPlaylist,
|
||||
),
|
||||
),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['recently']!,
|
||||
title: 'Recent Played',
|
||||
list: _recentlyPlaylist,
|
||||
),
|
||||
),
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
SliverList.builder(
|
||||
itemCount: _forYouView?.length ?? 0,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(item?.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item?.description ?? 'Please stand by...',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
GoRouter.of(context).pushNamed(
|
||||
'playlistView',
|
||||
pathParameters: {'id': item.id!},
|
||||
);
|
||||
},
|
||||
);
|
||||
final item = _forYouView![idx];
|
||||
final playlists = item['content']?['items']
|
||||
?.where((itemL2) => itemL2['type'] == 'playlist')
|
||||
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
|
||||
.toList()
|
||||
.cast<PlaylistSimple>() ??
|
||||
<PlaylistSimple>[];
|
||||
if (playlists.isEmpty) return const SizedBox.shrink();
|
||||
return PlaylistSection(
|
||||
isLoading: false,
|
||||
title: item['name'] ?? '',
|
||||
list: playlists,
|
||||
).paddingOnly(bottom: 16);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['featured']!,
|
||||
title: 'Featured',
|
||||
list: _featuredPlaylist,
|
||||
),
|
||||
),
|
||||
const SliverGap(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
43
lib/screens/library/view.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/widgets/no_login_fallback.dart';
|
||||
import 'package:rhythm_box/widgets/playlist/user_playlist_list.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
|
||||
class LibraryScreen extends StatefulWidget {
|
||||
const LibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryScreen> createState() => _LibraryScreenState();
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends State<LibraryScreen> {
|
||||
late final AuthenticationProvider _authenticate = Get.find();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Library'),
|
||||
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||
),
|
||||
body: Obx(() {
|
||||
if (_authenticate.auth.value == null) {
|
||||
return const NoLoginFallback();
|
||||
}
|
||||
|
||||
return const CenteredContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: UserPlaylistList()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
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 {
|
||||
@@ -22,13 +25,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
162
lib/screens/player/mini.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
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/widgets/lyrics/synced_lyrics.dart';
|
||||
import 'package:rhythm_box/widgets/player/bottom_player.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: _isHoverMode
|
||||
? const Icon(Icons.touch_app)
|
||||
: const Icon(Icons.touch_app_outlined),
|
||||
style: ButtonStyle(
|
||||
foregroundColor: _isHoverMode
|
||||
? WidgetStateProperty.all(theme.colorScheme.primary)
|
||||
: null,
|
||||
),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
_areaActive = true;
|
||||
_isHoverMode = !_isHoverMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (PlatformInfo.isDesktop)
|
||||
FutureBuilder(
|
||||
future: windowManager.isAlwaysOnTop(),
|
||||
builder: (context, snapshot) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
snapshot.data == true
|
||||
? Icons.push_pin
|
||||
: Icons.push_pin_outlined,
|
||||
),
|
||||
style: ButtonStyle(
|
||||
foregroundColor: snapshot.data == true
|
||||
? WidgetStateProperty.all(
|
||||
theme.colorScheme.primary)
|
||||
: null,
|
||||
),
|
||||
onPressed: snapshot.data == null
|
||||
? null
|
||||
: () async {
|
||||
await windowManager.setAlwaysOnTop(
|
||||
snapshot.data == true ? false : true,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const Expanded(child: SyncedLyrics(defaultTextZoom: 67)),
|
||||
SizedBox(
|
||||
height: 85,
|
||||
child: BottomPlayer(
|
||||
isMiniPlayer: true,
|
||||
usePop: true,
|
||||
onTap: () => _exitMiniPlayer(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/screens/player/siblings.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/widgets/player/sibling_tracks.dart';
|
||||
|
||||
class SiblingTracksPopup extends StatelessWidget {
|
||||
const SiblingTracksPopup({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Alternative Sources',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||
const Expanded(
|
||||
child: SiblingTracks(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,16 @@ 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/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 +31,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 +43,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 +52,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,
|
||||
@@ -102,20 +72,29 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: Row(
|
||||
children: [
|
||||
Hero(
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
children: [
|
||||
Obx(
|
||||
() => LimitedBox(
|
||||
maxHeight: maxAlbumSize,
|
||||
maxWidth: maxAlbumSize,
|
||||
child: Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _albumArt != null
|
||||
? AutoCacheImage(
|
||||
_albumArt!,
|
||||
width: size.width,
|
||||
height: size.width,
|
||||
width: albumSize,
|
||||
height: albumSize,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
@@ -123,24 +102,51 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
.surfaceContainerHigh,
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: const Center(child: Icon(Icons.image)),
|
||||
child: const Center(
|
||||
child: Icon(Icons.image)),
|
||||
),
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.name ?? 'Not playing',
|
||||
_playback.state.value.activeTrack?.name ??
|
||||
'Not playing',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.artists?.asString() ??
|
||||
_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(
|
||||
trackId: _playback.state.value.activeTrack!.id!,
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 32),
|
||||
),
|
||||
const Gap(24),
|
||||
Column(
|
||||
Obx(
|
||||
() => Column(
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
@@ -152,20 +158,27 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
secondaryTrackValue: _bufferProgress.abs(),
|
||||
secondaryTrackValue: _playback
|
||||
.durationBuffered.value.inMilliseconds
|
||||
.abs()
|
||||
.toDouble(),
|
||||
value: _draggingValue?.abs() ??
|
||||
_durationCurrent.inMilliseconds.toDouble().abs(),
|
||||
_playback.durationCurrent.value.inMilliseconds
|
||||
.toDouble()
|
||||
.abs(),
|
||||
min: 0,
|
||||
max: max(
|
||||
_durationTotal.inMilliseconds.abs(),
|
||||
_durationTotal.inMilliseconds.abs(),
|
||||
_playback.durationCurrent.value.inMilliseconds
|
||||
.abs(),
|
||||
_playback.durationTotal.value.inMilliseconds
|
||||
.abs(),
|
||||
).toDouble(),
|
||||
onChanged: (value) {
|
||||
setState(() => _draggingValue = value);
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
print('Seek to $value ms');
|
||||
audioPlayer.seek(Duration(milliseconds: value.toInt()));
|
||||
audioPlayer.seek(
|
||||
Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -173,17 +186,20 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(_durationCurrent),
|
||||
_playback.durationCurrent.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(_durationTotal),
|
||||
_playback.durationTotal.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8, vertical: 4),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
),
|
||||
const Gap(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -194,7 +210,9 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
final shuffled = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
shuffled ? Icons.shuffle_on_outlined : Icons.shuffle,
|
||||
shuffled
|
||||
? Icons.shuffle_on_outlined
|
||||
: Icons.shuffle,
|
||||
),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
@@ -208,18 +226,21 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToPrevious,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
SizedBox(
|
||||
Obx(
|
||||
() => SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: IconButton.filled(
|
||||
icon: _isFetchingActiveTrack
|
||||
icon: (_isFetchingActiveTrack && _isPlaying)
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
@@ -229,18 +250,23 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
||||
!_isPlaying
|
||||
? Icons.play_arrow
|
||||
: Icons.pause,
|
||||
size: 28,
|
||||
),
|
||||
onPressed:
|
||||
_isFetchingActiveTrack ? null : _togglePlayState,
|
||||
onPressed: _togglePlayState,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed:
|
||||
_isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToNext,
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
@@ -256,8 +282,10 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
: () async {
|
||||
await audioPlayer.setLoopMode(
|
||||
switch (_loopMode) {
|
||||
PlaylistMode.loop => PlaylistMode.single,
|
||||
PlaylistMode.single => PlaylistMode.none,
|
||||
PlaylistMode.loop =>
|
||||
PlaylistMode.single,
|
||||
PlaylistMode.single =>
|
||||
PlaylistMode.none,
|
||||
PlaylistMode.none => PlaylistMode.loop,
|
||||
},
|
||||
);
|
||||
@@ -287,7 +315,8 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
if (!isLargeScreen) const Gap(4),
|
||||
if (!isLargeScreen)
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.lyrics),
|
||||
@@ -302,14 +331,34 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.merge),
|
||||
label: const Text('Sources'),
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const SiblingTracksPopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
).marginAll(24),
|
||||
),
|
||||
if (isLargeScreen) const Gap(24),
|
||||
if (isLargeScreen)
|
||||
const Expanded(
|
||||
child: SyncedLyrics(defaultTextZoom: 67),
|
||||
)
|
||||
],
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:rhythm_box/providers/history.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@@ -36,19 +37,46 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
: false;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingTracks = true;
|
||||
bool _isUpdating = false;
|
||||
|
||||
Playlist? _playlist;
|
||||
List<Track>? _tracks;
|
||||
|
||||
Future<void> _pullPlaylist() async {
|
||||
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,7 +98,8 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
return CenteredContainer(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
@@ -83,13 +113,16 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Hero(
|
||||
tag: Key('playlist-cover-${_playlist!.id}'),
|
||||
child: AutoCacheImage(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -100,8 +133,9 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
children: [
|
||||
Text(
|
||||
_playlist!.name ?? 'Playlist',
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
@@ -112,7 +146,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers!.total!)} saves",
|
||||
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers?.total! ?? 0)} saves",
|
||||
),
|
||||
Text(
|
||||
'#${_playlist!.id}',
|
||||
@@ -149,14 +183,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
|
||||
setState(() => _isUpdating = true);
|
||||
|
||||
final tracks = (await _spotify
|
||||
.api.playlists
|
||||
.getTracksByPlaylistId(
|
||||
widget.playlistId)
|
||||
.all())
|
||||
.toList();
|
||||
|
||||
await _playback.load(tracks,
|
||||
await _playback.load(_tracks!,
|
||||
autoPlay: true);
|
||||
_playback.addCollection(_playlist!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
@@ -176,17 +203,11 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
|
||||
audioPlayer.setShuffle(true);
|
||||
|
||||
final tracks = (await _spotify.api.playlists
|
||||
.getTracksByPlaylistId(
|
||||
widget.playlistId)
|
||||
.all())
|
||||
.toList();
|
||||
|
||||
await _playback.load(
|
||||
tracks,
|
||||
_tracks!,
|
||||
autoPlay: true,
|
||||
initialIndex:
|
||||
Random().nextInt(tracks.length),
|
||||
Random().nextInt(_tracks!.length),
|
||||
);
|
||||
_playback.addCollection(_playlist!.id!);
|
||||
Get.find<PlaybackHistoryProvider>()
|
||||
@@ -203,12 +224,17 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Songs (${_playlist!.tracks!.total})',
|
||||
'Songs (${_playlist!.tracks?.total ?? (_tracks?.length ?? 0)})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).paddingOnly(left: 28, right: 28, bottom: 4),
|
||||
),
|
||||
PlaylistTrackList(playlistId: widget.playlistId),
|
||||
PlaylistTrackList(
|
||||
isLoading: _isLoadingTracks,
|
||||
playlistId: widget.playlistId,
|
||||
tracks: _tracks,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/track_list.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@@ -61,6 +62,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).paddingSymmetric(horizontal: 24, vertical: 8),
|
||||
Expanded(
|
||||
child: CenteredContainer(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (_searchResult != null)
|
||||
@@ -68,6 +70,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
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/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -8,8 +16,128 @@ 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: Column(
|
||||
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();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('Log out'),
|
||||
subtitle: const Text('Disconnect with this Spotify account'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
_authenticate.logout();
|
||||
},
|
||||
);
|
||||
}),
|
||||
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,
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('More information about this app'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
GoRouter.of(context).pushNamed('about');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
lib/services/album.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
extension AlbumExtensions on AlbumSimple {
|
||||
Album toAlbum() {
|
||||
Album album = Album();
|
||||
album.albumType = albumType;
|
||||
album.artists = artists;
|
||||
album.availableMarkets = availableMarkets;
|
||||
album.externalUrls = externalUrls;
|
||||
album.href = href;
|
||||
album.id = id;
|
||||
album.images = images;
|
||||
album.name = name;
|
||||
album.releaseDate = releaseDate;
|
||||
album.releaseDatePrecision = releaseDatePrecision;
|
||||
album.tracks = tracks;
|
||||
album.type = type;
|
||||
album.uri = uri;
|
||||
return album;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
26
lib/services/duration.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
extension DurationToHumanReadableString on Duration {
|
||||
String toHumanReadableString({padZero = true}) {
|
||||
final mm = inMinutes
|
||||
.remainder(60)
|
||||
.toString()
|
||||
.padLeft(2, !padZero && inHours == 0 ? '' : '0');
|
||||
final ss = inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
|
||||
if (inHours > 0) {
|
||||
final hh = inHours.toString().padLeft(2, !padZero ? '' : '0');
|
||||
return '$hh:$mm:$ss';
|
||||
}
|
||||
|
||||
return '$mm:$ss';
|
||||
}
|
||||
}
|
||||
|
||||
extension ParseDuration on Duration {
|
||||
static Duration fromString(String duration) {
|
||||
final parts = duration.split(':').reversed.toList();
|
||||
final seconds = int.parse(parts[0]);
|
||||
final minutes = parts.length > 1 ? int.parse(parts[1]) : 0;
|
||||
final hours = parts.length > 2 ? int.parse(parts[2]) : 0;
|
||||
return Duration(hours: hours, minutes: minutes, seconds: seconds);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,13 @@ class SyncedLyricsProvider extends GetxController {
|
||||
return lyrics;
|
||||
} catch (e, stackTrace) {
|
||||
log('[Lyrics] Error: $e; Trace:\n$stackTrace');
|
||||
rethrow;
|
||||
return SubtitleSimple(
|
||||
uri: Uri.parse('https://example.com/not-found'),
|
||||
name: 'Lyrics Not Found',
|
||||
lyrics: [],
|
||||
rating: 0,
|
||||
provider: 'Not Found',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:developer';
|
||||
|
||||
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/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 +20,31 @@ class ActiveSourcedTrackProvider extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> swapSibling(SourceInfo sibling) async {
|
||||
final query = Get.find<QueryingTrackInfoProvider>();
|
||||
query.isQueryingTrackInfo.value = true;
|
||||
|
||||
try {
|
||||
if (state.value == null) return;
|
||||
await audioPlayer.pause();
|
||||
await populateSibling();
|
||||
final newTrack = await state.value!.swapWithSibling(sibling);
|
||||
if (newTrack == null) return;
|
||||
|
||||
state.value = newTrack;
|
||||
await audioPlayer.pause();
|
||||
|
||||
final playback = Get.find<AudioPlayerProvider>();
|
||||
final oldActiveIndex = audioPlayer.currentIndex;
|
||||
|
||||
await playback.addTracksAtFirst([newTrack]);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
await playback.jumpToTrack(newTrack);
|
||||
await Future.delayed(const Duration(milliseconds: 30));
|
||||
|
||||
await audioPlayer.removeTrack(oldActiveIndex);
|
||||
|
||||
await playback.jumpToTrack(newTrack);
|
||||
} catch (e, stack) {
|
||||
log('[Playback] Failed to swap with siblings. Error: $e; Trace:\n$stack');
|
||||
} finally {
|
||||
query.isQueryingTrackInfo.value = false;
|
||||
await audioPlayer.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
class ServerPlaybackRoutesProvider {
|
||||
@@ -21,10 +20,10 @@ 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);
|
||||
|
||||
final res = await Dio().get(
|
||||
sourcedTrack!.url,
|
||||
|
||||
21
lib/services/song_link/song_link.freezed.dart
Executable file → Normal file
@@ -30,8 +30,12 @@ mixin _$SongLink {
|
||||
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
|
||||
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SongLink to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$SongLinkCopyWith<SongLink> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
@@ -63,6 +67,8 @@ class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
@@ -145,6 +151,8 @@ class __$$SongLinkImplCopyWithImpl<$Res>
|
||||
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
@@ -261,12 +269,14 @@ class _$SongLinkImpl implements _SongLink {
|
||||
other.nativeAppUriDesktop == nativeAppUriDesktop));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
|
||||
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||
@@ -313,8 +323,11 @@ abstract class _SongLink implements SongLink {
|
||||
String? get nativeAppUriMobile;
|
||||
@override
|
||||
String? get nativeAppUriDesktop;
|
||||
|
||||
/// Create a copy of SongLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
@@ -249,8 +249,8 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final searchResults = await youtubeClient.search.search(
|
||||
'$query - Topic',
|
||||
filter: TypeFilters.video,
|
||||
query,
|
||||
filter: const SearchFilter('CAMSAhAB'),
|
||||
);
|
||||
|
||||
if (ServiceUtils.onlyContainsEnglish(query)) {
|
||||
|
||||
214
lib/services/spotify/spotify_endpoints.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:rhythm_box/services/spotify/spotify_feed.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
class CustomSpotifyEndpoints {
|
||||
static const _baseUrl = 'https://api.spotify.com/v1';
|
||||
final String accessToken;
|
||||
final Dio _client;
|
||||
|
||||
CustomSpotifyEndpoints(this.accessToken)
|
||||
: _client = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
responseType: ResponseType.json,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
if (accessToken.isNotEmpty)
|
||||
'authorization': 'Bearer $accessToken',
|
||||
'accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// views API
|
||||
|
||||
/// Get a single view of given genre
|
||||
///
|
||||
/// Currently known genres are:
|
||||
/// - new-releases-page
|
||||
/// - made-for-x-hub (it requires authentication)
|
||||
/// - my-mix-genres (it requires authentication)
|
||||
/// - artist-seed-mixes (it requires authentication)
|
||||
/// - my-mix-decades (it requires authentication)
|
||||
/// - my-mix-moods (it requires authentication)
|
||||
/// - podcasts-and-more (it requires authentication)
|
||||
/// - uniquely-yours-in-hub (it requires authentication)
|
||||
/// - made-for-x-dailymix (it requires authentication)
|
||||
/// - made-for-x-discovery (it requires authentication)
|
||||
Future<Map<String, dynamic>> getView(
|
||||
String view, {
|
||||
int limit = 20,
|
||||
int contentLimit = 10,
|
||||
List<String> types = const [
|
||||
'album',
|
||||
'playlist',
|
||||
'artist',
|
||||
'show',
|
||||
'station',
|
||||
'episode',
|
||||
'merch',
|
||||
'artist_concerts',
|
||||
'uri_link'
|
||||
],
|
||||
String imageStyle = 'gradient_overlay',
|
||||
String includeExternal = 'audio',
|
||||
String? locale,
|
||||
Market? market,
|
||||
Market? country,
|
||||
}) async {
|
||||
if (accessToken.isEmpty) {
|
||||
throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty');
|
||||
}
|
||||
|
||||
final queryParams = {
|
||||
'limit': limit.toString(),
|
||||
'content_limit': contentLimit.toString(),
|
||||
'types': types.join(','),
|
||||
'image_style': imageStyle,
|
||||
'include_external': includeExternal,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
if (locale != null) 'locale': locale,
|
||||
if (market != null) 'market': market.name,
|
||||
if (country != null) 'country': country.name,
|
||||
}.entries.map((e) => '${e.key}=${e.value}').join('&');
|
||||
|
||||
final res = await _client.getUri(
|
||||
Uri.parse('$_baseUrl/views/$view?$queryParams'),
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
return res.data;
|
||||
} else {
|
||||
throw Exception(
|
||||
'[CustomSpotifyEndpoints.getView]: Failed to get view'
|
||||
'\nStatus code: ${res.statusCode}'
|
||||
'\nBody: ${res.data}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> listGenreSeeds() async {
|
||||
final res = await _client.getUri(
|
||||
Uri.parse('$_baseUrl/recommendations/available-genre-seeds'),
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final body = res.data;
|
||||
return List<String>.from(body['genres'] ?? []);
|
||||
} else {
|
||||
throw Exception(
|
||||
'[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds'
|
||||
'\nStatus code: ${res.statusCode}'
|
||||
'\nBody: ${res.data}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SpotifyHomeFeed> getHomeFeed({
|
||||
required String spTCookie,
|
||||
required Market country,
|
||||
}) async {
|
||||
final headers = {
|
||||
'app-platform': 'WebPlayer',
|
||||
'authorization': 'Bearer $accessToken',
|
||||
'content-type': 'application/json;charset=UTF-8',
|
||||
'dnt': '1',
|
||||
'origin': 'https://open.spotify.com',
|
||||
'referer': 'https://open.spotify.com/'
|
||||
};
|
||||
final response = await _client.getUri(
|
||||
Uri(
|
||||
scheme: 'https',
|
||||
host: 'api-partner.spotify.com',
|
||||
path: '/pathfinder/v1/query',
|
||||
queryParameters: {
|
||||
'operationName': 'home',
|
||||
'variables': jsonEncode({
|
||||
'timeZone': tz.local.name,
|
||||
'sp_t': spTCookie,
|
||||
'country': country.name,
|
||||
'facet': null,
|
||||
'sectionItemsLimit': 10
|
||||
}),
|
||||
'extensions': jsonEncode(
|
||||
{
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
|
||||
/// GraphQL persisted Query hash
|
||||
/// This can change overtime. We've to lookout for it
|
||||
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
|
||||
'sha256Hash':
|
||||
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
final data = SpotifyHomeFeed.fromJson(
|
||||
transformHomeFeedJsonMap(response.data),
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<SpotifyHomeFeedSection> getHomeFeedSection(
|
||||
String sectionUri, {
|
||||
required String spTCookie,
|
||||
required Market country,
|
||||
}) async {
|
||||
final headers = {
|
||||
'app-platform': 'WebPlayer',
|
||||
'authorization': 'Bearer $accessToken',
|
||||
'content-type': 'application/json;charset=UTF-8',
|
||||
'dnt': '1',
|
||||
'origin': 'https://open.spotify.com',
|
||||
'referer': 'https://open.spotify.com/'
|
||||
};
|
||||
final response = await _client.getUri(
|
||||
Uri(
|
||||
scheme: 'https',
|
||||
host: 'api-partner.spotify.com',
|
||||
path: '/pathfinder/v1/query',
|
||||
queryParameters: {
|
||||
'operationName': 'homeSection',
|
||||
'variables': jsonEncode({
|
||||
'timeZone': tz.local.name,
|
||||
'sp_t': spTCookie,
|
||||
'country': country.name,
|
||||
'uri': sectionUri
|
||||
}),
|
||||
'extensions': jsonEncode(
|
||||
{
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
|
||||
/// GraphQL persisted Query hash
|
||||
/// This can change overtime. We've to lookout for it
|
||||
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
|
||||
'sha256Hash':
|
||||
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
final data = SpotifyHomeFeedSection.fromJson(
|
||||
transformSectionItemJsonMap(
|
||||
response.data['data']['homeSections']['sections'][0],
|
||||
),
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
247
lib/services/spotify/spotify_feed.dart
Normal file
@@ -0,0 +1,247 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
part 'spotify_feed.freezed.dart';
|
||||
part 'spotify_feed.g.dart';
|
||||
|
||||
@freezed
|
||||
class SpotifySectionPlaylist with _$SpotifySectionPlaylist {
|
||||
const SpotifySectionPlaylist._();
|
||||
|
||||
const factory SpotifySectionPlaylist({
|
||||
required String description,
|
||||
required String format,
|
||||
required List<SpotifySectionItemImage> images,
|
||||
required String name,
|
||||
required String owner,
|
||||
required String uri,
|
||||
}) = _SpotifySectionPlaylist;
|
||||
|
||||
factory SpotifySectionPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySectionPlaylistFromJson(json);
|
||||
|
||||
String get id => uri.split(':').last;
|
||||
|
||||
Playlist get asPlaylist {
|
||||
return Playlist()
|
||||
..id = id
|
||||
..name = name
|
||||
..description = description
|
||||
..collaborative = false
|
||||
..images = images.map((e) => e.asImage).toList()
|
||||
..owner = (User()..displayName = 'Spotify')
|
||||
..uri = uri
|
||||
..type = 'playlist';
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifySectionArtist with _$SpotifySectionArtist {
|
||||
const SpotifySectionArtist._();
|
||||
|
||||
const factory SpotifySectionArtist({
|
||||
required String name,
|
||||
required String uri,
|
||||
required List<SpotifySectionItemImage> images,
|
||||
}) = _SpotifySectionArtist;
|
||||
|
||||
factory SpotifySectionArtist.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySectionArtistFromJson(json);
|
||||
|
||||
String get id => uri.split(':').last;
|
||||
|
||||
Artist get asArtist {
|
||||
return Artist()
|
||||
..id = id
|
||||
..name = name
|
||||
..images = images.map((e) => e.asImage).toList()
|
||||
..type = 'artist'
|
||||
..uri = uri;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifySectionAlbum with _$SpotifySectionAlbum {
|
||||
const SpotifySectionAlbum._();
|
||||
|
||||
const factory SpotifySectionAlbum({
|
||||
required List<SpotifySectionAlbumArtist> artists,
|
||||
required List<SpotifySectionItemImage> images,
|
||||
required String name,
|
||||
required String uri,
|
||||
}) = _SpotifySectionAlbum;
|
||||
|
||||
factory SpotifySectionAlbum.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySectionAlbumFromJson(json);
|
||||
|
||||
String get id => uri.split(':').last;
|
||||
|
||||
Album get asAlbum {
|
||||
return Album()
|
||||
..id = id
|
||||
..name = name
|
||||
..artists = artists.map((a) => a.asArtist).toList()
|
||||
..albumType = AlbumType.album
|
||||
..images = images.map((e) => e.asImage).toList()
|
||||
..uri = uri;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist {
|
||||
const SpotifySectionAlbumArtist._();
|
||||
|
||||
const factory SpotifySectionAlbumArtist({
|
||||
required String name,
|
||||
required String uri,
|
||||
}) = _SpotifySectionAlbumArtist;
|
||||
|
||||
factory SpotifySectionAlbumArtist.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySectionAlbumArtistFromJson(json);
|
||||
|
||||
String get id => uri.split(':').last;
|
||||
|
||||
Artist get asArtist {
|
||||
return Artist()
|
||||
..id = id
|
||||
..name = name
|
||||
..type = 'artist'
|
||||
..uri = uri;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifySectionItemImage with _$SpotifySectionItemImage {
|
||||
const SpotifySectionItemImage._();
|
||||
|
||||
const factory SpotifySectionItemImage({
|
||||
required num? height,
|
||||
required String url,
|
||||
required num? width,
|
||||
}) = _SpotifySectionItemImage;
|
||||
|
||||
factory SpotifySectionItemImage.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySectionItemImageFromJson(json);
|
||||
|
||||
Image get asImage {
|
||||
return Image()
|
||||
..height = height?.toInt()
|
||||
..width = width?.toInt()
|
||||
..url = url;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem {
|
||||
factory SpotifyHomeFeedSectionItem({
|
||||
required String typename,
|
||||
SpotifySectionPlaylist? playlist,
|
||||
SpotifySectionArtist? artist,
|
||||
SpotifySectionAlbum? album,
|
||||
}) = _SpotifyHomeFeedSectionItem;
|
||||
|
||||
factory SpotifyHomeFeedSectionItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedSectionItemFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection {
|
||||
factory SpotifyHomeFeedSection({
|
||||
required String typename,
|
||||
String? title,
|
||||
required String uri,
|
||||
required List<SpotifyHomeFeedSectionItem> items,
|
||||
}) = _SpotifyHomeFeedSection;
|
||||
|
||||
factory SpotifyHomeFeedSection.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedSectionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SpotifyHomeFeed with _$SpotifyHomeFeed {
|
||||
factory SpotifyHomeFeed({
|
||||
required String greeting,
|
||||
required List<SpotifyHomeFeedSection> sections,
|
||||
}) = _SpotifyHomeFeed;
|
||||
|
||||
factory SpotifyHomeFeed.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedFromJson(json);
|
||||
}
|
||||
|
||||
Map<String, dynamic> transformSectionItemTypeJsonMap(
|
||||
Map<String, dynamic> json) {
|
||||
final data = json['content']['data'];
|
||||
final objType = json['content']['data']['__typename'];
|
||||
return {
|
||||
'typename': json['content']['__typename'],
|
||||
if (objType == 'Playlist')
|
||||
'playlist': {
|
||||
'name': data['name'],
|
||||
'description': data['description'],
|
||||
'format': data['format'],
|
||||
'images': (data['images']['items'] as List)
|
||||
.expand((j) => j['sources'] as dynamic)
|
||||
.toList()
|
||||
.cast<Map<String, dynamic>>(),
|
||||
'owner': data['ownerV2']['data']['name'],
|
||||
'uri': data['uri']
|
||||
},
|
||||
if (objType == 'Artist')
|
||||
'artist': {
|
||||
'name': data['profile']['name'],
|
||||
'uri': data['uri'],
|
||||
'images': data['visuals']['avatarImage']['sources'],
|
||||
},
|
||||
if (objType == 'Album')
|
||||
'album': {
|
||||
'name': data['name'],
|
||||
'uri': data['uri'],
|
||||
'images': data['coverArt']['sources'],
|
||||
'artists': data['artists']['items']
|
||||
.map(
|
||||
(artist) => {
|
||||
'name': artist['profile']['name'],
|
||||
'uri': artist['uri'],
|
||||
},
|
||||
)
|
||||
.toList()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> transformSectionItemJsonMap(Map<String, dynamic> json) {
|
||||
return {
|
||||
'typename': json['data']['__typename'],
|
||||
'title': json['data']?['title']?['text'],
|
||||
'uri': json['uri'],
|
||||
'items': (json['sectionItems']['items'] as List)
|
||||
.map(
|
||||
(data) =>
|
||||
transformSectionItemTypeJsonMap(data as Map<String, dynamic>)
|
||||
as dynamic,
|
||||
)
|
||||
.where(
|
||||
(w) =>
|
||||
w['playlist'] != null ||
|
||||
w['artist'] != null ||
|
||||
w['album'] != null,
|
||||
)
|
||||
.toList()
|
||||
.cast<Map<String, dynamic>>()
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> transformHomeFeedJsonMap(Map<String, dynamic> json) {
|
||||
return {
|
||||
'greeting': json['data']['home']['greeting']['text'],
|
||||
'sections':
|
||||
(json['data']['home']['sectionContainer']['sections']['items'] as List)
|
||||
.map(
|
||||
(item) =>
|
||||
transformSectionItemJsonMap(item as Map<String, dynamic>)
|
||||
as dynamic,
|
||||
)
|
||||
.toList()
|
||||
.cast<Map<String, dynamic>>()
|
||||
};
|
||||
}
|
||||
1776
lib/services/spotify/spotify_feed.freezed.dart
Normal file
169
lib/services/spotify/spotify_feed.g.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'spotify_feed.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifySectionPlaylistImpl(
|
||||
description: json['description'] as String,
|
||||
format: json['format'] as String,
|
||||
images: (json['images'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
name: json['name'] as String,
|
||||
owner: json['owner'] as String,
|
||||
uri: json['uri'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifySectionPlaylistImplToJson(
|
||||
_$SpotifySectionPlaylistImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'description': instance.description,
|
||||
'format': instance.format,
|
||||
'images': instance.images,
|
||||
'name': instance.name,
|
||||
'owner': instance.owner,
|
||||
'uri': instance.uri,
|
||||
};
|
||||
|
||||
_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifySectionArtistImpl(
|
||||
name: json['name'] as String,
|
||||
uri: json['uri'] as String,
|
||||
images: (json['images'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifySectionArtistImplToJson(
|
||||
_$SpotifySectionArtistImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'uri': instance.uri,
|
||||
'images': instance.images,
|
||||
};
|
||||
|
||||
_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifySectionAlbumImpl(
|
||||
artists: (json['artists'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
SpotifySectionAlbumArtist.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
images: (json['images'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
name: json['name'] as String,
|
||||
uri: json['uri'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifySectionAlbumImplToJson(
|
||||
_$SpotifySectionAlbumImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'artists': instance.artists,
|
||||
'images': instance.images,
|
||||
'name': instance.name,
|
||||
'uri': instance.uri,
|
||||
};
|
||||
|
||||
_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifySectionAlbumArtistImpl(
|
||||
name: json['name'] as String,
|
||||
uri: json['uri'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifySectionAlbumArtistImplToJson(
|
||||
_$SpotifySectionAlbumArtistImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'uri': instance.uri,
|
||||
};
|
||||
|
||||
_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifySectionItemImageImpl(
|
||||
height: json['height'] as num?,
|
||||
url: json['url'] as String,
|
||||
width: json['width'] as num?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifySectionItemImageImplToJson(
|
||||
_$SpotifySectionItemImageImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'height': instance.height,
|
||||
'url': instance.url,
|
||||
'width': instance.width,
|
||||
};
|
||||
|
||||
_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedSectionItemImpl(
|
||||
typename: json['typename'] as String,
|
||||
playlist: json['playlist'] == null
|
||||
? null
|
||||
: SpotifySectionPlaylist.fromJson(
|
||||
json['playlist'] as Map<String, dynamic>),
|
||||
artist: json['artist'] == null
|
||||
? null
|
||||
: SpotifySectionArtist.fromJson(
|
||||
json['artist'] as Map<String, dynamic>),
|
||||
album: json['album'] == null
|
||||
? null
|
||||
: SpotifySectionAlbum.fromJson(json['album'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson(
|
||||
_$SpotifyHomeFeedSectionItemImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'typename': instance.typename,
|
||||
'playlist': instance.playlist,
|
||||
'artist': instance.artist,
|
||||
'album': instance.album,
|
||||
};
|
||||
|
||||
_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedSectionImpl(
|
||||
typename: json['typename'] as String,
|
||||
title: json['title'] as String?,
|
||||
uri: json['uri'] as String,
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
SpotifyHomeFeedSectionItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifyHomeFeedSectionImplToJson(
|
||||
_$SpotifyHomeFeedSectionImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'typename': instance.typename,
|
||||
'title': instance.title,
|
||||
'uri': instance.uri,
|
||||
'items': instance.items,
|
||||
};
|
||||
|
||||
_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SpotifyHomeFeedImpl(
|
||||
greeting: json['greeting'] as String,
|
||||
sections: (json['sections'] as List<dynamic>)
|
||||
.map(
|
||||
(e) => SpotifyHomeFeedSection.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SpotifyHomeFeedImplToJson(
|
||||
_$SpotifyHomeFeedImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'greeting': instance.greeting,
|
||||
'sections': instance.sections,
|
||||
};
|
||||
70
lib/services/track.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:metadata_god/metadata_god.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
extension TrackExtensions on Track {
|
||||
Track fromFile(
|
||||
File file, {
|
||||
Metadata? metadata,
|
||||
String? art,
|
||||
}) {
|
||||
album = Album()
|
||||
..name = metadata?.album ?? 'Unknown'
|
||||
..images = [if (art != null) Image()..url = art]
|
||||
..genres = [if (metadata?.genre != null) metadata!.genre!]
|
||||
..artists = [
|
||||
Artist()
|
||||
..name = metadata?.albumArtist ?? 'Unknown'
|
||||
..id = metadata?.albumArtist ?? 'Unknown'
|
||||
..type = 'artist',
|
||||
]
|
||||
..id = metadata?.album
|
||||
..releaseDate = metadata?.year?.toString();
|
||||
artists = [
|
||||
Artist()
|
||||
..name = metadata?.artist ?? 'Unknown'
|
||||
..id = metadata?.artist ?? 'Unknown'
|
||||
];
|
||||
|
||||
id = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||
name = metadata?.title ?? basenameWithoutExtension(file.path);
|
||||
type = 'track';
|
||||
uri = file.path;
|
||||
durationMs = (metadata?.durationMs?.toInt() ?? 0);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackSimpleExtensions on TrackSimple {
|
||||
Track asTrack(AlbumSimple album) {
|
||||
Track track = Track();
|
||||
track.name = name;
|
||||
track.album = album;
|
||||
track.artists = artists;
|
||||
track.availableMarkets = availableMarkets;
|
||||
track.discNumber = discNumber;
|
||||
track.durationMs = durationMs;
|
||||
track.explicit = explicit;
|
||||
track.externalUrls = externalUrls;
|
||||
track.href = href;
|
||||
track.id = id;
|
||||
track.isPlayable = isPlayable;
|
||||
track.linkedFrom = linkedFrom;
|
||||
track.name = name;
|
||||
track.previewUrl = previewUrl;
|
||||
track.trackNumber = trackNumber;
|
||||
track.type = type;
|
||||
track.uri = uri;
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
extension TracksToMediaExtension on Iterable<Track> {
|
||||
List<RhythmMedia> asMediaList() {
|
||||
return map((track) => RhythmMedia(track)).toList();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
32
lib/shells/system_shell.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class SystemShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const SystemShell({super.key, required this.child});
|
||||
|
||||
@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: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const i18nEnglish = {
|
||||
'appName': 'RhythmBox',
|
||||
'explore': 'Explore',
|
||||
'library': 'Library',
|
||||
'settings': 'Settings',
|
||||
'search': 'Search',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const i18nSimplifiedChinese = {
|
||||
'appName': '韵律盒',
|
||||
'explore': '探索',
|
||||
'library': '资料库',
|
||||
'settings': '设置',
|
||||
'search': '搜索',
|
||||
};
|
||||
|
||||
53
lib/widgets/album/album_card.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
final AlbumSimple? item;
|
||||
|
||||
final Function? onTap;
|
||||
|
||||
const AlbumCard({super.key, required this.item, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: (item?.images?.isNotEmpty ?? false)
|
||||
? AutoCacheImage(item!.images!.first.url!)
|
||||
: const Center(child: Icon(Icons.image)),
|
||||
),
|
||||
).paddingSymmetric(vertical: 8),
|
||||
Text(
|
||||
item?.name ?? 'Loading...',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item?.artists?.asString() ?? 'Please stand by...',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8),
|
||||
onTap: () {
|
||||
if (onTap == null) return;
|
||||
onTap!();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@ 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() {
|
||||
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) {
|
||||
_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,23 +110,32 @@ 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)
|
||||
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 &&
|
||||
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||
lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_durationCurrent.inSeconds);
|
||||
_playback.durationCurrent.value.inSeconds);
|
||||
|
||||
if (_durationCurrent.inSeconds == lyricSlice.time.inSeconds &&
|
||||
if (_playback.durationCurrent.value.inSeconds ==
|
||||
lyricSlice.time.inSeconds &&
|
||||
_isLyricSynced) {
|
||||
_autoScrollController.scrollToIndex(
|
||||
idx,
|
||||
@@ -107,8 +154,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 +167,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 +182,50 @@ 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),
|
||||
duration: 500.ms,
|
||||
curve: Curves.decelerate,
|
||||
child: Text(
|
||||
lyricSlice.text,
|
||||
textAlign:
|
||||
MediaQuery.of(context).size.width >= 720
|
||||
? TextAlign.center
|
||||
: TextAlign.left,
|
||||
),
|
||||
);
|
||||
}).paddingSymmetric(horizontal: 12),
|
||||
}).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',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Text(
|
||||
"This song haven't lyrics that recorded in our database.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverGap(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
32
lib/widgets/no_login_fallback.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
|
||||
class NoLoginFallback extends StatelessWidget {
|
||||
const NoLoginFallback({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CenteredContainer(
|
||||
maxWidth: 280,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.login,
|
||||
size: 48,
|
||||
),
|
||||
const Gap(12),
|
||||
Text(
|
||||
'Connect with your Spotify',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Text(
|
||||
'You need to connect RhythmBox with your spotify account in settings page, so that we can access your library.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,28 @@ import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_services/image.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/player/controls.dart';
|
||||
import 'package:rhythm_box/widgets/player/devices.dart';
|
||||
import 'package:rhythm_box/widgets/player/track_details.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
import 'package:rhythm_box/widgets/volume_slider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class BottomPlayer extends StatefulWidget {
|
||||
final bool usePop;
|
||||
final bool isMiniPlayer;
|
||||
final Function? onTap;
|
||||
|
||||
const BottomPlayer({super.key, this.usePop = false});
|
||||
const BottomPlayer({
|
||||
super.key,
|
||||
this.usePop = false,
|
||||
this.isMiniPlayer = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BottomPlayer> createState() => _BottomPlayerState();
|
||||
@@ -41,32 +52,14 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
(_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,12 +102,12 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_durationCurrent != Duration.zero)
|
||||
if (_playback.durationCurrent.value != Duration.zero)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: _durationCurrent.inMilliseconds /
|
||||
max(_durationTotal.inMilliseconds, 1),
|
||||
end: _playback.durationCurrent.value.inMilliseconds /
|
||||
max(_playback.durationTotal.value.inMilliseconds, 1),
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
@@ -124,20 +117,30 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
child: _albumArt != null
|
||||
? AutoCacheImage(_albumArt!, width: 64, height: 64)
|
||||
? AutoCacheImage(
|
||||
_albumArt!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: const Center(child: Icon(Icons.image)),
|
||||
child: const Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -147,40 +150,78 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
track: _playback.state.value.activeTrack,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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.skip_next),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToNext,
|
||||
icon: const Icon(Icons.speaker, size: 18),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => const PlayerDevicePopup(),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton.filled(
|
||||
icon: _isFetchingActiveTrack
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
if (!widget.isMiniPlayer && PlatformInfo.isDesktop)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.picture_in_picture,
|
||||
size: 18,
|
||||
),
|
||||
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,
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
||||
),
|
||||
onPressed:
|
||||
_isFetchingActiveTrack ? null : _togglePlayState,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.onTap != null) {
|
||||
widget.onTap!();
|
||||
return;
|
||||
}
|
||||
if (widget.usePop) {
|
||||
GoRouter.of(context).pop();
|
||||
} else {
|
||||
|
||||
73
lib/widgets/player/controls.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
|
||||
class PlayerControls extends StatefulWidget {
|
||||
const PlayerControls({super.key});
|
||||
|
||||
@override
|
||||
State<PlayerControls> createState() => _PlayerControlsState();
|
||||
}
|
||||
|
||||
class _PlayerControlsState extends State<PlayerControls> {
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
late final QueryingTrackInfoProvider _query = Get.find();
|
||||
|
||||
bool get _isPlaying => _playback.isPlaying.value;
|
||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||
|
||||
Future<void> _togglePlayState() async {
|
||||
if (!audioPlayer.isPlaying) {
|
||||
await audioPlayer.resume();
|
||||
} else {
|
||||
await audioPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
() => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MediaQuery.of(context).size.width >= 720
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (MediaQuery.of(context).size.width >= 720)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed:
|
||||
_isFetchingActiveTrack ? null : audioPlayer.skipToPrevious,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
||||
),
|
||||
IconButton.filled(
|
||||
icon: (_isFetchingActiveTrack && _isPlaying)
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying ? Icons.play_arrow : Icons.pause,
|
||||
),
|
||||
onPressed: _togglePlayState,
|
||||
),
|
||||
if (MediaQuery.of(context).size.width >= 720)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/widgets/player/devices.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
|
||||
class PlayerDevicePopup extends StatefulWidget {
|
||||
const PlayerDevicePopup({super.key});
|
||||
|
||||
@override
|
||||
State<PlayerDevicePopup> createState() => _PlayerDevicePopupState();
|
||||
}
|
||||
|
||||
class _PlayerDevicePopupState extends State<PlayerDevicePopup> {
|
||||
late Future<List<AudioDevice>> devicesFuture;
|
||||
late Stream<List<AudioDevice>> devicesStream;
|
||||
late Future<AudioDevice> selectedDeviceFuture;
|
||||
late Stream<AudioDevice> selectedDeviceStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
devicesFuture = audioPlayer.devices;
|
||||
devicesStream = audioPlayer.devicesStream;
|
||||
selectedDeviceFuture = audioPlayer.selectedDevice;
|
||||
selectedDeviceStream = audioPlayer.selectedDeviceStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Devices',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<AudioDevice>>(
|
||||
stream: devicesStream,
|
||||
builder: (context, devicesSnapshot) {
|
||||
return FutureBuilder<List<AudioDevice>>(
|
||||
future: devicesFuture,
|
||||
builder: (context, devicesFutureSnapshot) {
|
||||
final devices =
|
||||
devicesSnapshot.data ?? devicesFutureSnapshot.data;
|
||||
|
||||
return StreamBuilder<AudioDevice>(
|
||||
stream: selectedDeviceStream,
|
||||
builder: (context, selectedDeviceSnapshot) {
|
||||
return FutureBuilder<AudioDevice>(
|
||||
future: selectedDeviceFuture,
|
||||
builder: (context, selectedDeviceFutureSnapshot) {
|
||||
final selectedDevice = selectedDeviceSnapshot.data ??
|
||||
selectedDeviceFutureSnapshot.data;
|
||||
|
||||
if (devices == null || selectedDevice == null) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final device = devices[idx];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.speaker),
|
||||
title: Text(device.description),
|
||||
subtitle: Text(device.name),
|
||||
selected: selectedDevice == device,
|
||||
onTap: () => audioPlayer.setAudioDevice(device),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/widgets/player/sibling_tracks.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/duration.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
import 'package:rhythm_box/services/utils.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class SiblingTracks extends StatefulWidget {
|
||||
const SiblingTracks({super.key});
|
||||
|
||||
@override
|
||||
State<SiblingTracks> createState() => _SiblingTracksState();
|
||||
}
|
||||
|
||||
class _SiblingTracksState extends State<SiblingTracks> {
|
||||
late final QueryingTrackInfoProvider _query = Get.find();
|
||||
late final ActiveSourcedTrackProvider _activeSource = Get.find();
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
|
||||
final TextEditingController _searchTermController = TextEditingController();
|
||||
|
||||
Track? get _activeTrack =>
|
||||
_activeSource.state.value ?? _playback.state.value.activeTrack;
|
||||
|
||||
List<SourceInfo> _siblings = List.empty(growable: true);
|
||||
|
||||
final sourceInfoToLabelMap = {
|
||||
YoutubeSourceInfo: 'YouTube',
|
||||
PipedSourceInfo: 'Piped',
|
||||
};
|
||||
|
||||
List<StreamSubscription>? _subscriptions;
|
||||
|
||||
String? _lastActiveTrackId;
|
||||
|
||||
void _updateSiblings() {
|
||||
_siblings = List.from(
|
||||
!_query.isQueryingTrackInfo.value
|
||||
? [
|
||||
(_activeTrack as SourcedTrack).sourceInfo,
|
||||
..._activeSource.state.value!.siblings,
|
||||
]
|
||||
: [],
|
||||
growable: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSearchTerm() {
|
||||
if (_lastActiveTrackId == _activeTrack?.id) return;
|
||||
|
||||
final title = ServiceUtils.getTitle(
|
||||
_activeTrack?.name ?? '',
|
||||
artists: _activeTrack?.artists?.map((e) => e.name!).toList() ?? [],
|
||||
onlyCleanArtist: true,
|
||||
).trim();
|
||||
|
||||
final defaultSearchTerm =
|
||||
'$title - ${_activeTrack?.artists?.asString() ?? ''}';
|
||||
|
||||
_searchTermController.text = defaultSearchTerm;
|
||||
}
|
||||
|
||||
bool _isSearching = false;
|
||||
|
||||
Future<void> _searchSiblings() async {
|
||||
if (_isSearching) return;
|
||||
if (_searchTermController.text.trim().isEmpty) return;
|
||||
|
||||
_siblings.clear();
|
||||
setState(() => _isSearching = true);
|
||||
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final searchTerm = _searchTermController.text.trim();
|
||||
|
||||
if (preferences.audioSource == AudioSource.youtube ||
|
||||
preferences.audioSource == AudioSource.piped) {
|
||||
final resultsYt = await youtubeClient.search.search(searchTerm.trim());
|
||||
|
||||
final searchResults = await Future.wait(
|
||||
resultsYt.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async {
|
||||
final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video);
|
||||
return siblingType.info;
|
||||
}),
|
||||
);
|
||||
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||
_siblings = List.from(
|
||||
searchResults
|
||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||
..insert(
|
||||
0,
|
||||
activeSourceInfo,
|
||||
),
|
||||
growable: true,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateSearchTerm();
|
||||
_updateSiblings();
|
||||
_subscriptions = [
|
||||
_playback.state.listen((value) async {
|
||||
if (value.activeTrack != null) {
|
||||
_updateSearchTerm();
|
||||
_updateSiblings();
|
||||
setState(() {});
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchTermController.dispose();
|
||||
if (_subscriptions != null) {
|
||||
for (final subscription in _subscriptions!) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: TextField(
|
||||
controller: _searchTermController,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: 'search'.tr,
|
||||
),
|
||||
onSubmitted: (_) {
|
||||
_searchSiblings();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isSearching) const LinearProgressIndicator(minHeight: 3),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _siblings.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = _siblings[idx];
|
||||
final src = sourceInfoToLabelMap[item.runtimeType];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
item.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AutoCacheImage(
|
||||
item.thumbnail,
|
||||
height: 64,
|
||||
width: 64,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
trailing: Text(
|
||||
item.duration.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
if (src != null) Text(src),
|
||||
Expanded(
|
||||
child: Text(
|
||||
' · ${item.artist}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
enabled: !_query.isQueryingTrackInfo.value,
|
||||
tileColor: !_query.isQueryingTrackInfo.value &&
|
||||
item.id == (_activeTrack as SourcedTrack).sourceInfo.id
|
||||
? Theme.of(context).colorScheme.secondaryContainer
|
||||
: null,
|
||||
onTap: () {
|
||||
if (!_query.isQueryingTrackInfo.value &&
|
||||
item.id != (_activeTrack as SourcedTrack).sourceInfo.id) {
|
||||
_activeSource.swapSibling(item);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/widgets/playlist/playlist_card.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
final PlaylistSimple? item;
|
||||
|
||||
final Function? onTap;
|
||||
|
||||
const PlaylistCard({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?.description ?? 'Please stand by...',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8),
|
||||
onTap: () {
|
||||
if (onTap == null) return;
|
||||
onTap!();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/widgets/playlist/playlist_section.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/widgets/album/album_card.dart';
|
||||
import 'package:rhythm_box/widgets/playlist/playlist_card.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class PlaylistSection extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final String title;
|
||||
final List<Object>? list;
|
||||
|
||||
const PlaylistSection({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.title,
|
||||
required this.list,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: MediaQuery.of(context).size.width >= 720
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).paddingOnly(left: 32, right: 32, bottom: 4),
|
||||
SizedBox(
|
||||
height: 280,
|
||||
width: double.infinity,
|
||||
child: Skeletonizer(
|
||||
enabled: isLoading,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: list?.length ?? 20,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = list?[idx];
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: switch (item.runtimeType) {
|
||||
const (AlbumSimple) || const (Album) => AlbumCard(
|
||||
item: item as AlbumSimple?,
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
GoRouter.of(context).pushNamed(
|
||||
'albumView',
|
||||
pathParameters: {'id': item.id!},
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => PlaylistCard(
|
||||
item: item as PlaylistSimple?,
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
GoRouter.of(context).pushNamed(
|
||||
'playlistView',
|
||||
pathParameters: {'id': item.id!},
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/widgets/playlist/playlist_tile.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class PlaylistTile extends StatelessWidget {
|
||||
final PlaylistSimple? item;
|
||||
|
||||
final Function? onTap;
|
||||
|
||||
const PlaylistTile({super.key, required this.item, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: (item?.images?.isNotEmpty ?? false)
|
||||
? AutoCacheImage(
|
||||
item!.images!.first.url!,
|
||||
width: 64.0,
|
||||
height: 64.0,
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(item?.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item?.description ?? 'Please stand by...',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
if (onTap == null) return;
|
||||
onTap!();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/widgets/playlist/user_playlist_list.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/widgets/playlist/playlist_tile.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class UserPlaylistList extends StatefulWidget {
|
||||
const UserPlaylistList({super.key});
|
||||
|
||||
@override
|
||||
State<UserPlaylistList> createState() => _UserPlaylistListState();
|
||||
}
|
||||
|
||||
class _UserPlaylistListState extends State<UserPlaylistList> {
|
||||
late final SpotifyProvider _spotify = Get.find();
|
||||
|
||||
PlaylistSimple get _userLikedPlaylist => PlaylistSimple()
|
||||
..name = 'Liked Music'
|
||||
..description = 'Your favorite music'
|
||||
..type = 'playlist'
|
||||
..collaborative = false
|
||||
..public = false
|
||||
..id = 'user-liked-tracks';
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
List<PlaylistSimple>? _playlist;
|
||||
|
||||
Future<void> _pullPlaylist() async {
|
||||
_playlist = [_userLikedPlaylist, ...await _spotify.api.playlists.me.all()];
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pullPlaylist();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer(
|
||||
enabled: _isLoading,
|
||||
child: ListView.builder(
|
||||
itemCount: _playlist?.length ?? 3,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = _playlist?[idx];
|
||||
return PlaylistTile(
|
||||
item: item,
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
GoRouter.of(context).pushNamed(
|
||||
'playlistView',
|
||||
pathParameters: {'id': item.id!},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/widgets/sized_container.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SizedContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double maxWidth;
|
||||
final double maxHeight;
|
||||
|
||||
const SizedContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.maxWidth = 720,
|
||||
this.maxHeight = double.infinity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CenteredContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double maxWidth;
|
||||
|
||||
const CenteredContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.maxWidth = 720,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/widgets/tracks/heart_button.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
|
||||
class TrackHeartButton extends StatefulWidget {
|
||||
final String trackId;
|
||||
|
||||
const TrackHeartButton({super.key, required this.trackId});
|
||||
|
||||
@override
|
||||
State<TrackHeartButton> createState() => _TrackHeartButtonState();
|
||||
}
|
||||
|
||||
class _TrackHeartButtonState extends State<TrackHeartButton> {
|
||||
late final SpotifyProvider _spotify = Get.find();
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
bool _isLiked = false;
|
||||
|
||||
Future<void> _pullHeart() async {
|
||||
final res = await _spotify.api.tracks.me.containsOne(widget.trackId);
|
||||
setState(() {
|
||||
_isLiked = res;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleHeart() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
if (_isLiked) {
|
||||
await _spotify.api.tracks.me.removeOne(widget.trackId);
|
||||
_isLiked = false;
|
||||
} else {
|
||||
await _spotify.api.tracks.me.saveOne(widget.trackId);
|
||||
_isLiked = true;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pullHeart();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: AnimatedSwitcher(
|
||||
switchInCurve: Curves.fastOutSlowIn,
|
||||
switchOutCurve: Curves.fastOutSlowIn,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
_isLiked ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
|
||||
key: ValueKey(_isLiked),
|
||||
color: _isLiked ? Colors.red[600] : null,
|
||||
),
|
||||
),
|
||||
onPressed: _isLoading ? null : _toggleHeart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/track_tile.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
|
||||
class PlaylistTrackList extends StatefulWidget {
|
||||
class PlaylistTrackList extends StatelessWidget {
|
||||
final String playlistId;
|
||||
final List<Track>? tracks;
|
||||
|
||||
const PlaylistTrackList({super.key, required this.playlistId});
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
State<PlaylistTrackList> createState() => _PlaylistTrackListState();
|
||||
}
|
||||
|
||||
class _PlaylistTrackListState extends State<PlaylistTrackList> {
|
||||
late final SpotifyProvider _spotify = Get.find();
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
List<Track>? _tracks;
|
||||
|
||||
Future<void> _pullTracks() async {
|
||||
_tracks = (await _spotify.api.playlists
|
||||
.getTracksByPlaylistId(widget.playlistId)
|
||||
.all())
|
||||
.toList();
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_pullTracks();
|
||||
super.initState();
|
||||
}
|
||||
const PlaylistTrackList({
|
||||
super.key,
|
||||
this.isLoading = false,
|
||||
required this.playlistId,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Skeletonizer.sliver(
|
||||
enabled: _isLoading,
|
||||
enabled: isLoading,
|
||||
child: SliverList.builder(
|
||||
itemCount: _tracks?.length ?? 3,
|
||||
itemCount: tracks?.length ?? 3,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = _tracks?[idx];
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: item != null
|
||||
? AutoCacheImage(
|
||||
item.album!.images!.first.url!,
|
||||
width: 64.0,
|
||||
height: 64.0,
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(item?.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item?.artists?.asString() ?? 'Please stand by...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
final item = tracks?[idx];
|
||||
return TrackTile(
|
||||
item: item,
|
||||
onTap: () {
|
||||
if (item == null) return;
|
||||
Get.find<AudioPlayerProvider>()
|
||||
..load(
|
||||
_tracks!,
|
||||
tracks!,
|
||||
initialIndex: idx,
|
||||
autoPlay: true,
|
||||
)
|
||||
..addCollection(widget.playlistId);
|
||||
..addCollection(playlistId);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/track_tile.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
|
||||
class TrackSliverList extends StatelessWidget {
|
||||
final List<Track> tracks;
|
||||
@@ -19,21 +18,8 @@ class TrackSliverList extends StatelessWidget {
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final item = tracks[idx];
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AutoCacheImage(
|
||||
item.album!.images!.first.url!,
|
||||
width: 64.0,
|
||||
height: 64.0,
|
||||
),
|
||||
),
|
||||
title: Text(item.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item.artists?.asString() ?? 'Please stand by...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
return TrackTile(
|
||||
item: item,
|
||||
onTap: () {
|
||||
Get.find<AudioPlayerProvider>().load(
|
||||
[item],
|
||||
|
||||
44
lib/widgets/tracks/track_tile.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
|
||||
class TrackTile extends StatelessWidget {
|
||||
final Track? item;
|
||||
|
||||
final Function? onTap;
|
||||
|
||||
const TrackTile({super.key, required this.item, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: (item?.album?.images?.isNotEmpty ?? false)
|
||||
? AutoCacheImage(
|
||||
item!.album!.images!.first.url!,
|
||||
width: 64.0,
|
||||
height: 64.0,
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(item?.name ?? 'Loading...'),
|
||||
subtitle: Text(
|
||||
item?.artists?.asString() ?? 'Please stand by...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
if (onTap == null) return;
|
||||
onTap!();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/widgets/volume_slider.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/volume.dart';
|
||||
|
||||
class VolumeSlider extends StatelessWidget {
|
||||
final MainAxisAlignment mainAxisAlignment;
|
||||
|
||||
const VolumeSlider({
|
||||
super.key,
|
||||
this.mainAxisAlignment = MainAxisAlignment.start,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final VolumeProvider vol = Get.find();
|
||||
|
||||
final slider = Listener(
|
||||
onPointerSignal: (event) async {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
final newValue = vol.volume.value - .2;
|
||||
vol.setVolume(newValue < 0 ? 0 : newValue);
|
||||
} else {
|
||||
final newValue = vol.volume.value + .2;
|
||||
vol.setVolume(newValue > 1 ? 1 : newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
showValueIndicator: ShowValueIndicator.always,
|
||||
trackShape: _VolumeSliderShape(),
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
label: (vol.volume.value * 100).toStringAsFixed(0),
|
||||
value: vol.volume.value,
|
||||
onChanged: vol.setVolume,
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(horizontal: 8);
|
||||
return Row(
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
vol.volume.value == 0
|
||||
? Icons.volume_off
|
||||
: vol.volume.value <= 0.5
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
if (vol.volume.value == 0) {
|
||||
vol.setVolume(1);
|
||||
} else {
|
||||
vol.setVolume(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
SizedBox(width: 100, child: slider),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _VolumeSliderShape extends RoundedRectSliderTrackShape {
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
required RenderBox parentBox,
|
||||
Offset offset = Offset.zero,
|
||||
required SliderThemeData sliderTheme,
|
||||
bool isEnabled = false,
|
||||
bool isDiscrete = false,
|
||||
}) {
|
||||
final trackHeight = sliderTheme.trackHeight;
|
||||
final trackLeft = offset.dx;
|
||||
final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
|
||||
final trackWidth = parentBox.size.width;
|
||||
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "rhythm_box")
|
||||
set(BINARY_NAME "RhythmBox")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "dev.solsynth.rhythmBox")
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <desktop_webview_window/desktop_webview_window_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||
#include <screen_retriever/screen_retriever_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin");
|
||||
desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
@@ -25,6 +30,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_webview_window
|
||||
flutter_secure_storage_linux
|
||||
media_kit_libs_linux
|
||||
screen_retriever
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
media_kit_native_event_loop
|
||||
metadata_god
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "rhythm_box");
|
||||
gtk_header_bar_set_title(header_bar, "RhythmBox");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "rhythm_box");
|
||||
gtk_window_set_title(window, "RhythmBox");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -7,7 +7,9 @@ import Foundation
|
||||
|
||||
import audio_service
|
||||
import audio_session
|
||||
import desktop_webview_window
|
||||
import device_info_plus
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_secure_storage_macos
|
||||
import media_kit_libs_macos_audio
|
||||
import package_info_plus
|
||||
@@ -16,12 +18,15 @@ import screen_retriever
|
||||
import shared_preferences_foundation
|
||||
import sqflite
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
@@ -30,5 +35,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
||||
146
macos/Podfile.lock
Normal file
@@ -0,0 +1,146 @@
|
||||
PODS:
|
||||
- audio_service (0.14.1):
|
||||
- FlutterMacOS
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- desktop_webview_window (0.0.1):
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_inappwebview_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_secure_storage_macos (6.1.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- media_kit_libs_macos_audio (1.0.4):
|
||||
- FlutterMacOS
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- FlutterMacOS
|
||||
- metadata_god (0.0.1):
|
||||
- FlutterMacOS
|
||||
- OrderedSet (5.0.0)
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- screen_retriever (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- 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):
|
||||
- FlutterMacOS
|
||||
- "sqlite3 (~> 3.46.0+1)"
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- window_manager (0.2.0):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`)
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
||||
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- media_kit_libs_macos_audio (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos`)
|
||||
- media_kit_native_event_loop (from `Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos`)
|
||||
- metadata_god (from `Flutter/ephemeral/.symlinks/plugins/metadata_god/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`)
|
||||
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- OrderedSet
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
desktop_webview_window:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
flutter_inappwebview_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||
flutter_secure_storage_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
media_kit_libs_macos_audio:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos
|
||||
media_kit_native_event_loop:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos
|
||||
metadata_god:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/metadata_god/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
screen_retriever:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
sqflite:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin
|
||||
sqlite3_flutter_libs:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
window_manager:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
|
||||
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
|
||||
desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a
|
||||
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
|
||||
flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d
|
||||
flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da
|
||||
media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5
|
||||
metadata_god: 829f61208b44ac1173e7cd32ab740d8776be5435
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b
|
||||
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
|
||||
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
@@ -21,12 +21,14 @@
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1489AA3CFCC3A0C3CA533E1F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71EE4B7445B61FD5E5BECBC3 /* Pods_Runner.framework */; };
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
662B3DAEF129A60A4A18E1F4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4ED76FDC58E3E6DFA9084D7A /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -60,11 +62,12 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1CB096F9F1C0858C755D83BD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* rhythm_box.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "rhythm_box.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* rhythm_box.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = rhythm_box.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
@@ -76,8 +79,15 @@
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
4ED76FDC58E3E6DFA9084D7A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
6D3DDF6690286D5FA1568336 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
71EE4B7445B61FD5E5BECBC3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
9432957068C77D7544947DE0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
BB4ED370EF97034F76DDBE09 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
E523450CD8B62CE48D5098CF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F89453F80F705EA3CC90967E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -85,6 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
662B3DAEF129A60A4A18E1F4 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -92,12 +103,26 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1489AA3CFCC3A0C3CA533E1F /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0000804A301843F9BC4FC25D /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1CB096F9F1C0858C755D83BD /* Pods-Runner.debug.xcconfig */,
|
||||
E523450CD8B62CE48D5098CF /* Pods-Runner.release.xcconfig */,
|
||||
BB4ED370EF97034F76DDBE09 /* Pods-Runner.profile.xcconfig */,
|
||||
6D3DDF6690286D5FA1568336 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
F89453F80F705EA3CC90967E /* Pods-RunnerTests.release.xcconfig */,
|
||||
9432957068C77D7544947DE0 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -125,6 +150,7 @@
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
0000804A301843F9BC4FC25D /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -175,6 +201,8 @@
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
71EE4B7445B61FD5E5BECBC3 /* Pods_Runner.framework */,
|
||||
4ED76FDC58E3E6DFA9084D7A /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -186,6 +214,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
267F60974B0F58946DDEBCBF /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -204,11 +233,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
F48554762217E62E6C0BA507 /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
BCB6DCE828238EC782478754 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -291,6 +322,28 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
267F60974B0F58946DDEBCBF /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -329,6 +382,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
BCB6DCE828238EC782478754 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F48554762217E62E6C0BA507 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -380,12 +472,13 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 6D3DDF6690286D5FA1568336 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
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)/rhythm_box.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/rhythm_box";
|
||||
@@ -394,12 +487,13 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = F89453F80F705EA3CC90967E /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
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)/rhythm_box.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/rhythm_box";
|
||||
@@ -408,12 +502,13 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9432957068C77D7544947DE0 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
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)/rhythm_box.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/rhythm_box";
|
||||
@@ -476,9 +571,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -608,9 +707,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -628,9 +731,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 320 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 18 KiB |