25 Commits

Author SHA1 Message Date
4bf8715486 🐛 Fix timeout 2024-08-30 22:16:10 +08:00
fbb12ff801 🐛 Bug fixes 2024-08-30 22:06:24 +08:00
47d051dd44 🐛 Bug fixes 2024-08-30 21:53:40 +08:00
1ac7704080 🐛 Fix bugs on windows 2024-08-30 19:53:53 +08:00
b7b673c96d 🚀 Launch v1.0.0+1
Some checks failed
release-nightly / build-web (push) Has been cancelled
release-nightly / build-exe (push) Has been cancelled
2024-08-30 15:06:12 +08:00
f772bbdbbc :rocket Ready to launch! 2024-08-30 14:05:11 +08:00
a95292a9ef Better explore ever 2024-08-30 13:43:29 +08:00
07a86c32a0 🐛 Bug fixes 2024-08-30 13:23:57 +08:00
f16c216479 🍱 Update icons 2024-08-30 13:18:36 +08:00
8b8915e28f Mini player 2024-08-30 12:56:28 +08:00
0a24c86682 🚚 Update package name & label 2024-08-30 02:01:08 +08:00
d25ebbf6bd 🐛 Bug fixes 2024-08-30 01:56:27 +08:00
be977f10d1 Better explore 2024-08-30 01:38:02 +08:00
bb09c43135 Volume slider 2024-08-30 00:28:12 +08:00
989440013c 🐛 Fix put order issue 2024-08-29 23:07:49 +08:00
d80a398a23 Endless playback 2024-08-29 23:03:41 +08:00
3ca01ef147 🐛 Bug fixes and optimization 2024-08-29 22:39:54 +08:00
586f47575c Audio normalize 2024-08-29 22:34:56 +08:00
ef40c2ffe4 💫 Optimize lyrics 2024-08-29 19:10:54 +08:00
7e95c167ef :sparklesS: Able to search siblings tracks 2024-08-29 17:55:35 +08:00
a063d19952 User library 2024-08-29 16:42:48 +08:00
7285eb4959 Connect with spotify 2024-08-29 15:02:49 +08:00
be44aadc07 📱 Large screen support 2024-08-29 01:45:33 +08:00
249c8fbf80 ♻️ Improve progress display 2024-08-29 00:41:40 +08:00
2134500089 Alternative tracks 2024-08-29 00:33:59 +08:00
131 changed files with 7041 additions and 1719 deletions

24
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: release-nightly
on:
push:
branches: [master]
jobs:
build-exe:
runs-on: windows-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: flutter build windows
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-windows
path: build/windows/x64/runner/Release

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

BIN
assets/icon-w-shadow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

View File

@@ -8,10 +8,24 @@ PODS:
- Flutter (1.0.0)
- flutter_broadcasts (0.0.1):
- Flutter
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- media_kit_libs_ios_audio (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- metadata_god (0.0.1):
- Flutter
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -23,6 +37,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

View File

@@ -161,7 +161,6 @@
1CF40EE9C145DC3FDC6C41BF /* Pods-RunnerTests.release.xcconfig */,
DAFDCBCA918FE99EC399DF6B /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -474,11 +473,13 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -495,7 +496,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -513,7 +514,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -529,7 +530,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -657,11 +658,13 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -680,11 +683,13 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rhythmBox;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 339 KiB

View File

@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="2050" height="2048"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

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

View File

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

View File

@@ -7,10 +7,17 @@ import 'package:media_kit/media_kit.dart' hide Track;
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/services/audio_player/state.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/local_track.dart';
import 'package:rhythm_box/services/server/sourced_track.dart';
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:rhythm_box/services/audio_player/audio_player.dart';
class AudioPlayerProvider extends GetxController {
Rx<Duration> durationTotal = Rx(Duration.zero);
Rx<Duration> durationCurrent = Rx(Duration.zero);
Rx<Duration> durationBuffered = Rx(Duration.zero);
RxBool isPlaying = false.obs;
Rx<AudioPlayerState> state = Rx(AudioPlayerState(
@@ -54,6 +61,11 @@ class AudioPlayerProvider extends GetxController {
state.value = state.value.copyWith(playlist: playlist);
await _updatePlaylist(playlist);
}),
audioPlayer.durationStream.listen((value) => durationTotal.value = value),
audioPlayer.positionStream
.listen((value) => durationCurrent.value = value),
audioPlayer.bufferedPositionStream
.listen((value) => durationBuffered.value = value),
];
_readSavedState();
@@ -239,11 +251,12 @@ class AudioPlayerProvider extends GetxController {
// Giving the initial track a boost so MediaKit won't skip
// because of timeout
// final intendedActiveTrack = medias.elementAt(initialIndex);
// if (intendedActiveTrack.track is! LocalTrack) {
// await Get.find<SourcedTrackProvider>()
// .fetch(RhythmMedia(intendedActiveTrack.track));
// }
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
final intendedActiveTrack = medias.elementAt(initialIndex);
if (intendedActiveTrack.track is! LocalTrack) {
await Get.find<SourcedTrackProvider>()
.fetch(RhythmMedia(intendedActiveTrack.track));
}
if (medias.isEmpty) return;

140
lib/providers/auth.dart Normal file
View 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();
}
}

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

View File

@@ -7,8 +7,6 @@ class PaletteProvider extends GetxController {
void updatePalette(PaletteGenerator? newPalette) {
palette.value = newPalette;
print('call update!');
print(newPalette);
if (newPalette != null) {
Get.changeTheme(
ThemeData.from(

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/services/database/database.dart';
class RecentlyPlayedProvider extends GetxController {
Future<List<HistoryTableData>> fetch() async {
final database = Get.find<DatabaseProvider>().database;
final uniqueItemIds = await (database.selectOnly(
database.historyTable,
distinct: true,
)
..addColumns([database.historyTable.itemId, database.historyTable.id])
..where(
database.historyTable.type.isInValues([
HistoryEntryType.playlist,
HistoryEntryType.album,
]),
)
..limit(10)
..orderBy([
OrderingTerm(
expression: database.historyTable.createdAt,
mode: OrderingMode.desc,
),
]))
.map(
(row) => row.read(database.historyTable.id),
)
.get()
.then((value) => value.whereNotNull().toList());
final query = database.select(database.historyTable)
..where(
(tbl) => tbl.id.isIn(uniqueItemIds),
)
..orderBy([
(tbl) => OrderingTerm(
expression: tbl.createdAt,
mode: OrderingMode.desc,
),
]);
final fetchedItems = await query.get();
return fetchedItems;
}
}

View File

@@ -1,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
View File

@@ -0,0 +1,20 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:rhythm_box/services/kv_store/kv_store.dart';
class VolumeProvider extends GetxController {
RxDouble volume = KVStoreService.volume.obs;
@override
void onInit() {
super.onInit();
audioPlayer.setVolume(volume.value);
}
Future<void> setVolume(double newVolume) async {
volume.value = newVolume;
await audioPlayer.setVolume(newVolume);
KVStoreService.setVolume(newVolume);
}
}

View File

@@ -1,8 +1,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
View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:rhythm_box/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
const denseButtonStyle =
ButtonStyle(visualDensity: VisualDensity(vertical: -4));
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
title: const Text('About'),
),
body: SizedBox(
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/icon.png', width: 120, height: 120),
),
const SizedBox(height: 8),
Text(
PlatformInfo.isIOS || PlatformInfo.isMacOS
? 'GroovyBox'
: 'RhythmBox',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text(
'Yet another Spotify third-party app',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
return Text(
'v${snapshot.data!.version} · ${snapshot.data!.buildNumber}',
style: const TextStyle(fontFamily: 'monospace'),
);
},
),
Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
const SizedBox(height: 16),
TextButton(
style: denseButtonStyle,
child: const Text('App Details'),
onPressed: () async {
final info = await PackageInfo.fromPlatform();
showAboutDialog(
context: context,
applicationVersion: '${info.version} (${info.buildNumber})',
applicationLegalese: 'Yet another Spotify third-party app.',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child:
Image.asset('assets/icon.png', width: 60, height: 60),
),
);
},
),
TextButton(
style: denseButtonStyle,
child: const Text('Project Website'),
onPressed: () {
launchUrlString('https://solsynth.dev/products/rhythm-box');
},
),
const SizedBox(height: 16),
const Text(
'Open-sourced under AGPLv3',
style: TextStyle(
fontWeight: FontWeight.w300,
fontSize: 12,
),
),
],
),
),
);
}
}

229
lib/screens/album/view.dart Normal file
View File

@@ -0,0 +1,229 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:rhythm_box/providers/audio_player.dart';
import 'package:rhythm_box/providers/history.dart';
import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/services/artist.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:rhythm_box/services/track.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
import 'package:spotify/spotify.dart';
class AlbumViewScreen extends StatefulWidget {
final String albumId;
final Playlist? playlist;
const AlbumViewScreen({
super.key,
required this.albumId,
this.playlist,
});
@override
State<AlbumViewScreen> createState() => _AlbumViewScreenState();
}
class _AlbumViewScreenState extends State<AlbumViewScreen> {
late final SpotifyProvider _spotify = Get.find();
late final AudioPlayerProvider _playback = Get.find();
bool get _isCurrentPlaylist => _album != null
? _playback.state.value.containsCollection(_album!.id!)
: false;
bool _isLoading = true;
bool _isLoadingTracks = true;
bool _isUpdating = false;
Album? _album;
List<Track>? _tracks;
Future<void> _pullPlaylist() async {
_album = await _spotify.api.albums.get(widget.albumId);
setState(() => _isLoading = false);
}
Future<void> _pullTracks() async {
_tracks = (await _spotify.api.albums.tracks(widget.albumId).all())
.map((x) => x.asTrack(_album!))
.toList();
setState(() => _isLoadingTracks = false);
}
@override
void initState() {
super.initState();
_pullPlaylist();
_pullTracks();
}
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(8));
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: const Text('Playlist'),
centerTitle: MediaQuery.of(context).size.width >= 720,
),
body: Builder(
builder: (context) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CenteredContainer(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Material(
borderRadius: radius,
elevation: 2,
child: ClipRRect(
borderRadius: radius,
child: (_album?.images?.isNotEmpty ?? false)
? AutoCacheImage(
_album!.images!.first.url!,
width: 160.0,
height: 160.0,
)
: const SizedBox(
width: 160,
height: 160,
child: Icon(Icons.image),
),
),
),
const Gap(24),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_album!.name ?? 'Playlist',
style: Theme.of(context)
.textTheme
.headlineSmall,
maxLines: 2,
overflow: TextOverflow.fade,
),
Text(
_album!.artists?.asString() ?? 'A Playlist',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const Gap(8),
Text(
"${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_album!.popularity ?? 0)} views",
),
Text(
'#${_album!.id}',
style: GoogleFonts.robotoMono(fontSize: 10),
),
],
),
),
],
).paddingOnly(left: 24, right: 24, top: 24),
const Gap(8),
Wrap(
spacing: 8,
children: [
Obx(
() => ElevatedButton.icon(
icon: (_isCurrentPlaylist &&
_playback.isPlaying.value)
? const Icon(Icons.pause_outlined)
: const Icon(Icons.play_arrow),
label: const Text('Play'),
onPressed: _isUpdating
? null
: () async {
if (_isCurrentPlaylist &&
_playback.isPlaying.value) {
audioPlayer.pause();
return;
} else if (_isCurrentPlaylist &&
!_playback.isPlaying.value) {
audioPlayer.resume();
return;
}
setState(() => _isUpdating = true);
await _playback.load(_tracks!,
autoPlay: true);
_playback.addCollection(_album!.id!);
Get.find<PlaybackHistoryProvider>()
.addAlbums([_album!]);
setState(() => _isUpdating = false);
},
),
),
TextButton.icon(
icon: const Icon(Icons.shuffle),
label: const Text('Shuffle'),
onPressed: _isUpdating
? null
: () async {
setState(() => _isUpdating = true);
audioPlayer.setShuffle(true);
await _playback.load(
_tracks!,
autoPlay: true,
initialIndex:
Random().nextInt(_tracks!.length),
);
_playback.addCollection(_album!.id!);
Get.find<PlaybackHistoryProvider>()
.addAlbums([_album!]);
setState(() => _isUpdating = false);
},
),
],
).paddingSymmetric(horizontal: 24),
const Gap(24),
],
),
),
SliverToBoxAdapter(
child: Text(
'Songs (${_tracks?.length ?? 0})',
style: Theme.of(context).textTheme.titleLarge,
).paddingOnly(left: 28, right: 28, bottom: 4),
),
PlaylistTrackList(
isLoading: _isLoadingTracks,
playlistId: widget.albumId,
tracks: _tracks,
),
],
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/providers/auth.dart';
Future<void> desktopLogin(BuildContext context) async {
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
final applicationSupportDir = await getApplicationSupportDirectory();
final userDataFolder =
Directory(join(applicationSupportDir.path, 'webview_window_Webview2'));
if (!await userDataFolder.exists()) {
await userDataFolder.create();
}
final webview = await WebviewWindow.create(
configuration: CreateConfiguration(
title: 'Spotify Login',
titleBarTopPadding: PlatformInfo.isMacOS ? 20 : 0,
windowHeight: 720,
windowWidth: 1280,
userDataFolderWindows: userDataFolder.path,
),
);
webview
..setBrightness(Theme.of(context).colorScheme.brightness)
..launch('https://accounts.spotify.com/')
..setOnUrlRequestCallback((url) {
if (exp.hasMatch(url)) {
webview.getAllCookies().then((cookies) async {
final cookieHeader =
"sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}";
final AuthenticationProvider authenticate = Get.find();
await authenticate.login(cookieHeader);
webview.close();
if (context.mounted) {
GoRouter.of(context).go('/');
}
});
}
return true;
});
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/screens/auth/desktop_login.dart';
Future<void> universalLogin(BuildContext context) async {
if (PlatformInfo.isMobile) {
GoRouter.of(context).pushNamed('authMobileLogin');
return;
}
return await desktopLogin(context);
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/providers/auth.dart';
class MobileLogin extends StatelessWidget {
const MobileLogin({super.key});
@override
Widget build(BuildContext context) {
final AuthenticationProvider authenticate = Get.find();
if (PlatformInfo.isDesktop) {
const Scaffold(
body: Center(
child: Text('This feature is not available on desktop'),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Connect with Spotify'),
),
body: SafeArea(
child: InAppWebView(
initialSettings: InAppWebViewSettings(
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
),
initialUrlRequest: URLRequest(
url: WebUri('https://accounts.spotify.com/'),
),
onPermissionRequest: (controller, permissionRequest) async {
return PermissionResponse(
resources: permissionRequest.resources,
action: PermissionResponseAction.GRANT,
);
},
onLoadStop: (controller, action) async {
if (action == null) return;
String url = action.toString();
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
final exp = RegExp(r'https:\/\/accounts.spotify.com\/.+\/status');
if (exp.hasMatch(url)) {
final cookies =
await CookieManager.instance().getCookies(url: action);
final cookieHeader =
"sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}";
await authenticate.login(cookieHeader);
if (context.mounted) {
GoRouter.of(context).pop();
}
}
},
),
),
);
}
}

View File

@@ -1,9 +1,15 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:rhythm_box/providers/auth.dart';
import 'package:rhythm_box/providers/recent_played.dart';
import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/services/album.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/spotify/spotify_endpoints.dart';
import 'package:rhythm_box/widgets/playlist/playlist_section.dart';
import 'package:spotify/spotify.dart';
class ExploreScreen extends StatefulWidget {
@@ -15,15 +21,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),
],
),
),
);

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/providers/auth.dart';
import 'package:rhythm_box/widgets/no_login_fallback.dart';
import 'package:rhythm_box/widgets/playlist/user_playlist_list.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
class LibraryScreen extends StatefulWidget {
const LibraryScreen({super.key});
@override
State<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends State<LibraryScreen> {
late final AuthenticationProvider _authenticate = Get.find();
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: const Text('Library'),
centerTitle: MediaQuery.of(context).size.width >= 720,
),
body: Obx(() {
if (_authenticate.auth.value == null) {
return const NoLoginFallback();
}
return const CenteredContainer(
child: Column(
children: [
Expanded(child: UserPlaylistList()),
],
),
);
}),
),
);
}
}

View File

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

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

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/widgets/player/sibling_tracks.dart';
class SiblingTracksPopup extends StatelessWidget {
const SiblingTracksPopup({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Alternative Sources',
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
const Expanded(
child: SiblingTracks(),
)
],
),
);
}
}

View File

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

View File

@@ -10,6 +10,7 @@ import 'package:rhythm_box/providers/history.dart';
import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
import 'package:rhythm_box/widgets/tracks/playlist_track_list.dart';
import 'package:spotify/spotify.dart';
@@ -36,19 +37,46 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> {
: false;
bool _isLoading = true;
bool _isLoadingTracks = true;
bool _isUpdating = false;
Playlist? _playlist;
List<Track>? _tracks;
Future<void> _pullPlaylist() async {
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,
),
],
),
);
},
),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
import 'package:rhythm_box/widgets/tracks/track_list.dart';
import 'package:spotify/spotify.dart';
@@ -61,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> {
],
),
),
),
],
),
),

View File

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

@@ -0,0 +1,21 @@
import 'package:spotify/spotify.dart';
extension AlbumExtensions on AlbumSimple {
Album toAlbum() {
Album album = Album();
album.albumType = albumType;
album.artists = artists;
album.availableMarkets = availableMarkets;
album.externalUrls = externalUrls;
album.href = href;
album.id = id;
album.images = images;
album.name = name;
album.releaseDate = releaseDate;
album.releaseDatePrecision = releaseDatePrecision;
album.tracks = tracks;
album.type = type;
album.uri = uri;
return album;
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
extension DurationToHumanReadableString on Duration {
String toHumanReadableString({padZero = true}) {
final mm = inMinutes
.remainder(60)
.toString()
.padLeft(2, !padZero && inHours == 0 ? '' : '0');
final ss = inSeconds.remainder(60).toString().padLeft(2, '0');
if (inHours > 0) {
final hh = inHours.toString().padLeft(2, !padZero ? '' : '0');
return '$hh:$mm:$ss';
}
return '$mm:$ss';
}
}
extension ParseDuration on Duration {
static Duration fromString(String duration) {
final parts = duration.split(':').reversed.toList();
final seconds = int.parse(parts[0]);
final minutes = parts.length > 1 ? int.parse(parts[1]) : 0;
final hours = parts.length > 2 ? int.parse(parts[2]) : 0;
return Duration(hours: hours, minutes: minutes, seconds: seconds);
}
}

View File

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

View File

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

View File

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

@@ -30,8 +30,12 @@ mixin _$SongLink {
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
/// Serializes this SongLink to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SongLinkCopyWith<SongLink> get copyWith =>
throw _privateConstructorUsedError;
}
@@ -63,6 +67,8 @@ class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -145,6 +151,8 @@ class __$$SongLinkImplCopyWithImpl<$Res>
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
: super(_value, _then);
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
@@ -261,12 +269,14 @@ class _$SongLinkImpl implements _SongLink {
other.nativeAppUriDesktop == nativeAppUriDesktop));
}
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
@JsonKey(ignore: true)
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
@@ -313,8 +323,11 @@ abstract class _SongLink implements SongLink {
String? get nativeAppUriMobile;
@override
String? get nativeAppUriDesktop;
/// Create a copy of SongLink
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(ignore: true)
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

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

View File

@@ -0,0 +1,214 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:rhythm_box/services/spotify/spotify_feed.dart';
import 'package:spotify/spotify.dart';
import 'package:timezone/timezone.dart' as tz;
class CustomSpotifyEndpoints {
static const _baseUrl = 'https://api.spotify.com/v1';
final String accessToken;
final Dio _client;
CustomSpotifyEndpoints(this.accessToken)
: _client = Dio(
BaseOptions(
baseUrl: _baseUrl,
responseType: ResponseType.json,
headers: {
'content-type': 'application/json',
if (accessToken.isNotEmpty)
'authorization': 'Bearer $accessToken',
'accept': 'application/json',
},
),
);
// views API
/// Get a single view of given genre
///
/// Currently known genres are:
/// - new-releases-page
/// - made-for-x-hub (it requires authentication)
/// - my-mix-genres (it requires authentication)
/// - artist-seed-mixes (it requires authentication)
/// - my-mix-decades (it requires authentication)
/// - my-mix-moods (it requires authentication)
/// - podcasts-and-more (it requires authentication)
/// - uniquely-yours-in-hub (it requires authentication)
/// - made-for-x-dailymix (it requires authentication)
/// - made-for-x-discovery (it requires authentication)
Future<Map<String, dynamic>> getView(
String view, {
int limit = 20,
int contentLimit = 10,
List<String> types = const [
'album',
'playlist',
'artist',
'show',
'station',
'episode',
'merch',
'artist_concerts',
'uri_link'
],
String imageStyle = 'gradient_overlay',
String includeExternal = 'audio',
String? locale,
Market? market,
Market? country,
}) async {
if (accessToken.isEmpty) {
throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty');
}
final queryParams = {
'limit': limit.toString(),
'content_limit': contentLimit.toString(),
'types': types.join(','),
'image_style': imageStyle,
'include_external': includeExternal,
'timestamp': DateTime.now().toUtc().toIso8601String(),
if (locale != null) 'locale': locale,
if (market != null) 'market': market.name,
if (country != null) 'country': country.name,
}.entries.map((e) => '${e.key}=${e.value}').join('&');
final res = await _client.getUri(
Uri.parse('$_baseUrl/views/$view?$queryParams'),
);
if (res.statusCode == 200) {
return res.data;
} else {
throw Exception(
'[CustomSpotifyEndpoints.getView]: Failed to get view'
'\nStatus code: ${res.statusCode}'
'\nBody: ${res.data}',
);
}
}
Future<List<String>> listGenreSeeds() async {
final res = await _client.getUri(
Uri.parse('$_baseUrl/recommendations/available-genre-seeds'),
);
if (res.statusCode == 200) {
final body = res.data;
return List<String>.from(body['genres'] ?? []);
} else {
throw Exception(
'[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds'
'\nStatus code: ${res.statusCode}'
'\nBody: ${res.data}',
);
}
}
Future<SpotifyHomeFeed> getHomeFeed({
required String spTCookie,
required Market country,
}) async {
final headers = {
'app-platform': 'WebPlayer',
'authorization': 'Bearer $accessToken',
'content-type': 'application/json;charset=UTF-8',
'dnt': '1',
'origin': 'https://open.spotify.com',
'referer': 'https://open.spotify.com/'
};
final response = await _client.getUri(
Uri(
scheme: 'https',
host: 'api-partner.spotify.com',
path: '/pathfinder/v1/query',
queryParameters: {
'operationName': 'home',
'variables': jsonEncode({
'timeZone': tz.local.name,
'sp_t': spTCookie,
'country': country.name,
'facet': null,
'sectionItemsLimit': 10
}),
'extensions': jsonEncode(
{
'persistedQuery': {
'version': 1,
/// GraphQL persisted Query hash
/// This can change overtime. We've to lookout for it
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
'sha256Hash':
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
}
},
),
},
),
options: Options(headers: headers),
);
final data = SpotifyHomeFeed.fromJson(
transformHomeFeedJsonMap(response.data),
);
return data;
}
Future<SpotifyHomeFeedSection> getHomeFeedSection(
String sectionUri, {
required String spTCookie,
required Market country,
}) async {
final headers = {
'app-platform': 'WebPlayer',
'authorization': 'Bearer $accessToken',
'content-type': 'application/json;charset=UTF-8',
'dnt': '1',
'origin': 'https://open.spotify.com',
'referer': 'https://open.spotify.com/'
};
final response = await _client.getUri(
Uri(
scheme: 'https',
host: 'api-partner.spotify.com',
path: '/pathfinder/v1/query',
queryParameters: {
'operationName': 'homeSection',
'variables': jsonEncode({
'timeZone': tz.local.name,
'sp_t': spTCookie,
'country': country.name,
'uri': sectionUri
}),
'extensions': jsonEncode(
{
'persistedQuery': {
'version': 1,
/// GraphQL persisted Query hash
/// This can change overtime. We've to lookout for it
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
'sha256Hash':
'eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be',
}
},
),
},
),
options: Options(headers: headers),
);
final data = SpotifyHomeFeedSection.fromJson(
transformSectionItemJsonMap(
response.data['data']['homeSections']['sections'][0],
),
);
return data;
}
}

View File

@@ -0,0 +1,247 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotify/spotify.dart';
part 'spotify_feed.freezed.dart';
part 'spotify_feed.g.dart';
@freezed
class SpotifySectionPlaylist with _$SpotifySectionPlaylist {
const SpotifySectionPlaylist._();
const factory SpotifySectionPlaylist({
required String description,
required String format,
required List<SpotifySectionItemImage> images,
required String name,
required String owner,
required String uri,
}) = _SpotifySectionPlaylist;
factory SpotifySectionPlaylist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionPlaylistFromJson(json);
String get id => uri.split(':').last;
Playlist get asPlaylist {
return Playlist()
..id = id
..name = name
..description = description
..collaborative = false
..images = images.map((e) => e.asImage).toList()
..owner = (User()..displayName = 'Spotify')
..uri = uri
..type = 'playlist';
}
}
@freezed
class SpotifySectionArtist with _$SpotifySectionArtist {
const SpotifySectionArtist._();
const factory SpotifySectionArtist({
required String name,
required String uri,
required List<SpotifySectionItemImage> images,
}) = _SpotifySectionArtist;
factory SpotifySectionArtist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionArtistFromJson(json);
String get id => uri.split(':').last;
Artist get asArtist {
return Artist()
..id = id
..name = name
..images = images.map((e) => e.asImage).toList()
..type = 'artist'
..uri = uri;
}
}
@freezed
class SpotifySectionAlbum with _$SpotifySectionAlbum {
const SpotifySectionAlbum._();
const factory SpotifySectionAlbum({
required List<SpotifySectionAlbumArtist> artists,
required List<SpotifySectionItemImage> images,
required String name,
required String uri,
}) = _SpotifySectionAlbum;
factory SpotifySectionAlbum.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionAlbumFromJson(json);
String get id => uri.split(':').last;
Album get asAlbum {
return Album()
..id = id
..name = name
..artists = artists.map((a) => a.asArtist).toList()
..albumType = AlbumType.album
..images = images.map((e) => e.asImage).toList()
..uri = uri;
}
}
@freezed
class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist {
const SpotifySectionAlbumArtist._();
const factory SpotifySectionAlbumArtist({
required String name,
required String uri,
}) = _SpotifySectionAlbumArtist;
factory SpotifySectionAlbumArtist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionAlbumArtistFromJson(json);
String get id => uri.split(':').last;
Artist get asArtist {
return Artist()
..id = id
..name = name
..type = 'artist'
..uri = uri;
}
}
@freezed
class SpotifySectionItemImage with _$SpotifySectionItemImage {
const SpotifySectionItemImage._();
const factory SpotifySectionItemImage({
required num? height,
required String url,
required num? width,
}) = _SpotifySectionItemImage;
factory SpotifySectionItemImage.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionItemImageFromJson(json);
Image get asImage {
return Image()
..height = height?.toInt()
..width = width?.toInt()
..url = url;
}
}
@freezed
class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem {
factory SpotifyHomeFeedSectionItem({
required String typename,
SpotifySectionPlaylist? playlist,
SpotifySectionArtist? artist,
SpotifySectionAlbum? album,
}) = _SpotifyHomeFeedSectionItem;
factory SpotifyHomeFeedSectionItem.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionItemFromJson(json);
}
@freezed
class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection {
factory SpotifyHomeFeedSection({
required String typename,
String? title,
required String uri,
required List<SpotifyHomeFeedSectionItem> items,
}) = _SpotifyHomeFeedSection;
factory SpotifyHomeFeedSection.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionFromJson(json);
}
@freezed
class SpotifyHomeFeed with _$SpotifyHomeFeed {
factory SpotifyHomeFeed({
required String greeting,
required List<SpotifyHomeFeedSection> sections,
}) = _SpotifyHomeFeed;
factory SpotifyHomeFeed.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedFromJson(json);
}
Map<String, dynamic> transformSectionItemTypeJsonMap(
Map<String, dynamic> json) {
final data = json['content']['data'];
final objType = json['content']['data']['__typename'];
return {
'typename': json['content']['__typename'],
if (objType == 'Playlist')
'playlist': {
'name': data['name'],
'description': data['description'],
'format': data['format'],
'images': (data['images']['items'] as List)
.expand((j) => j['sources'] as dynamic)
.toList()
.cast<Map<String, dynamic>>(),
'owner': data['ownerV2']['data']['name'],
'uri': data['uri']
},
if (objType == 'Artist')
'artist': {
'name': data['profile']['name'],
'uri': data['uri'],
'images': data['visuals']['avatarImage']['sources'],
},
if (objType == 'Album')
'album': {
'name': data['name'],
'uri': data['uri'],
'images': data['coverArt']['sources'],
'artists': data['artists']['items']
.map(
(artist) => {
'name': artist['profile']['name'],
'uri': artist['uri'],
},
)
.toList()
},
};
}
Map<String, dynamic> transformSectionItemJsonMap(Map<String, dynamic> json) {
return {
'typename': json['data']['__typename'],
'title': json['data']?['title']?['text'],
'uri': json['uri'],
'items': (json['sectionItems']['items'] as List)
.map(
(data) =>
transformSectionItemTypeJsonMap(data as Map<String, dynamic>)
as dynamic,
)
.where(
(w) =>
w['playlist'] != null ||
w['artist'] != null ||
w['album'] != null,
)
.toList()
.cast<Map<String, dynamic>>()
};
}
Map<String, dynamic> transformHomeFeedJsonMap(Map<String, dynamic> json) {
return {
'greeting': json['data']['home']['greeting']['text'],
'sections':
(json['data']['home']['sectionContainer']['sections']['items'] as List)
.map(
(item) =>
transformSectionItemJsonMap(item as Map<String, dynamic>)
as dynamic,
)
.toList()
.cast<Map<String, dynamic>>()
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'spotify_feed.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionPlaylistImpl(
description: json['description'] as String,
format: json['format'] as String,
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
name: json['name'] as String,
owner: json['owner'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionPlaylistImplToJson(
_$SpotifySectionPlaylistImpl instance) =>
<String, dynamic>{
'description': instance.description,
'format': instance.format,
'images': instance.images,
'name': instance.name,
'owner': instance.owner,
'uri': instance.uri,
};
_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionArtistImpl(
name: json['name'] as String,
uri: json['uri'] as String,
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifySectionArtistImplToJson(
_$SpotifySectionArtistImpl instance) =>
<String, dynamic>{
'name': instance.name,
'uri': instance.uri,
'images': instance.images,
};
_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionAlbumImpl(
artists: (json['artists'] as List<dynamic>)
.map((e) =>
SpotifySectionAlbumArtist.fromJson(e as Map<String, dynamic>))
.toList(),
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
name: json['name'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionAlbumImplToJson(
_$SpotifySectionAlbumImpl instance) =>
<String, dynamic>{
'artists': instance.artists,
'images': instance.images,
'name': instance.name,
'uri': instance.uri,
};
_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionAlbumArtistImpl(
name: json['name'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionAlbumArtistImplToJson(
_$SpotifySectionAlbumArtistImpl instance) =>
<String, dynamic>{
'name': instance.name,
'uri': instance.uri,
};
_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionItemImageImpl(
height: json['height'] as num?,
url: json['url'] as String,
width: json['width'] as num?,
);
Map<String, dynamic> _$$SpotifySectionItemImageImplToJson(
_$SpotifySectionItemImageImpl instance) =>
<String, dynamic>{
'height': instance.height,
'url': instance.url,
'width': instance.width,
};
_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionItemImpl(
typename: json['typename'] as String,
playlist: json['playlist'] == null
? null
: SpotifySectionPlaylist.fromJson(
json['playlist'] as Map<String, dynamic>),
artist: json['artist'] == null
? null
: SpotifySectionArtist.fromJson(
json['artist'] as Map<String, dynamic>),
album: json['album'] == null
? null
: SpotifySectionAlbum.fromJson(json['album'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson(
_$SpotifyHomeFeedSectionItemImpl instance) =>
<String, dynamic>{
'typename': instance.typename,
'playlist': instance.playlist,
'artist': instance.artist,
'album': instance.album,
};
_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionImpl(
typename: json['typename'] as String,
title: json['title'] as String?,
uri: json['uri'] as String,
items: (json['items'] as List<dynamic>)
.map((e) =>
SpotifyHomeFeedSectionItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifyHomeFeedSectionImplToJson(
_$SpotifyHomeFeedSectionImpl instance) =>
<String, dynamic>{
'typename': instance.typename,
'title': instance.title,
'uri': instance.uri,
'items': instance.items,
};
_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedImpl(
greeting: json['greeting'] as String,
sections: (json['sections'] as List<dynamic>)
.map(
(e) => SpotifyHomeFeedSection.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifyHomeFeedImplToJson(
_$SpotifyHomeFeedImpl instance) =>
<String, dynamic>{
'greeting': instance.greeting,
'sections': instance.sections,
};

70
lib/services/track.dart Normal file
View File

@@ -0,0 +1,70 @@
import 'dart:io';
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:spotify/spotify.dart';
extension TrackExtensions on Track {
Track fromFile(
File file, {
Metadata? metadata,
String? art,
}) {
album = Album()
..name = metadata?.album ?? 'Unknown'
..images = [if (art != null) Image()..url = art]
..genres = [if (metadata?.genre != null) metadata!.genre!]
..artists = [
Artist()
..name = metadata?.albumArtist ?? 'Unknown'
..id = metadata?.albumArtist ?? 'Unknown'
..type = 'artist',
]
..id = metadata?.album
..releaseDate = metadata?.year?.toString();
artists = [
Artist()
..name = metadata?.artist ?? 'Unknown'
..id = metadata?.artist ?? 'Unknown'
];
id = metadata?.title ?? basenameWithoutExtension(file.path);
name = metadata?.title ?? basenameWithoutExtension(file.path);
type = 'track';
uri = file.path;
durationMs = (metadata?.durationMs?.toInt() ?? 0);
return this;
}
}
extension TrackSimpleExtensions on TrackSimple {
Track asTrack(AlbumSimple album) {
Track track = Track();
track.name = name;
track.album = album;
track.artists = artists;
track.availableMarkets = availableMarkets;
track.discNumber = discNumber;
track.durationMs = durationMs;
track.explicit = explicit;
track.externalUrls = externalUrls;
track.href = href;
track.id = id;
track.isPlayable = isPlayable;
track.linkedFrom = linkedFrom;
track.name = name;
track.previewUrl = previewUrl;
track.trackNumber = trackNumber;
track.type = type;
track.uri = uri;
return track;
}
}
extension TracksToMediaExtension on Iterable<Track> {
List<RhythmMedia> asMediaList() {
return map((track) => RhythmMedia(track)).toList();
}
}

View File

@@ -24,6 +24,7 @@ class _NavShellState extends State<NavShell> {
final List<Destination> _allDestinations = <Destination>[
Destination('explore'.tr, 'explore', Icons.explore),
Destination('library'.tr, 'library', Icons.video_library),
Destination('search'.tr, 'search', Icons.search),
Destination('settings'.tr, 'settings', Icons.settings),
];
@@ -40,6 +41,8 @@ class _NavShellState extends State<NavShell> {
const BottomPlayer(key: Key('app-wide-bottom-player')),
const Divider(height: 0.3, thickness: 0.3),
BottomNavigationBar(
type: BottomNavigationBarType.fixed,
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
elevation: 0,
showUnselectedLabels: false,
currentIndex: _focusDestination,

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:rhythm_box/services/artist.dart';
import 'package:spotify/spotify.dart';
class AlbumCard extends StatelessWidget {
final AlbumSimple? item;
final Function? onTap;
const AlbumCard({super.key, required this.item, this.onTap});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 1,
child: (item?.images?.isNotEmpty ?? false)
? AutoCacheImage(item!.images!.first.url!)
: const Center(child: Icon(Icons.image)),
),
).paddingSymmetric(vertical: 8),
Text(
item?.name ?? 'Loading...',
style: Theme.of(context).textTheme.bodyLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Expanded(
child: Text(
item?.artists?.asString() ?? 'Please stand by...',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
).paddingSymmetric(horizontal: 8),
onTap: () {
if (onTap == null) return;
onTap!();
},
),
);
}
}

View File

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

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
class NoLoginFallback extends StatelessWidget {
const NoLoginFallback({super.key});
@override
Widget build(BuildContext context) {
return CenteredContainer(
maxWidth: 280,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.login,
size: 48,
),
const Gap(12),
Text(
'Connect with your Spotify',
style: Theme.of(context).textTheme.titleLarge,
),
const Text(
'You need to connect RhythmBox with your spotify account in settings page, so that we can access your library.',
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -5,17 +5,28 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/providers/audio_player.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:rhythm_box/services/audio_services/image.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:rhythm_box/widgets/player/controls.dart';
import 'package:rhythm_box/widgets/player/devices.dart';
import 'package:rhythm_box/widgets/player/track_details.dart';
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
import 'package:rhythm_box/widgets/volume_slider.dart';
import 'package:window_manager/window_manager.dart';
class BottomPlayer extends StatefulWidget {
final bool usePop;
final bool isMiniPlayer;
final Function? onTap;
const BottomPlayer({super.key, this.usePop = false});
const BottomPlayer({
super.key,
this.usePop = false,
this.isMiniPlayer = false,
this.onTap,
});
@override
State<BottomPlayer> createState() => _BottomPlayerState();
@@ -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 {

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/providers/audio_player.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
class PlayerControls extends StatefulWidget {
const PlayerControls({super.key});
@override
State<PlayerControls> createState() => _PlayerControlsState();
}
class _PlayerControlsState extends State<PlayerControls> {
late final AudioPlayerProvider _playback = Get.find();
late final QueryingTrackInfoProvider _query = Get.find();
bool get _isPlaying => _playback.isPlaying.value;
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
Future<void> _togglePlayState() async {
if (!audioPlayer.isPlaying) {
await audioPlayer.resume();
} else {
await audioPlayer.pause();
}
}
@override
Widget build(BuildContext context) {
return Obx(
() => Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MediaQuery.of(context).size.width >= 720
? MainAxisAlignment.center
: MainAxisAlignment.end,
children: [
if (MediaQuery.of(context).size.width >= 720)
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed:
_isFetchingActiveTrack ? null : audioPlayer.skipToPrevious,
)
else
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
),
IconButton.filled(
icon: (_isFetchingActiveTrack && _isPlaying)
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
)
: Icon(
!_isPlaying ? Icons.play_arrow : Icons.pause,
),
onPressed: _togglePlayState,
),
if (MediaQuery.of(context).size.width >= 720)
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: _isFetchingActiveTrack ? null : audioPlayer.skipToNext,
)
],
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart';
import 'package:rhythm_box/services/audio_player/audio_player.dart';
class PlayerDevicePopup extends StatefulWidget {
const PlayerDevicePopup({super.key});
@override
State<PlayerDevicePopup> createState() => _PlayerDevicePopupState();
}
class _PlayerDevicePopupState extends State<PlayerDevicePopup> {
late Future<List<AudioDevice>> devicesFuture;
late Stream<List<AudioDevice>> devicesStream;
late Future<AudioDevice> selectedDeviceFuture;
late Stream<AudioDevice> selectedDeviceStream;
@override
void initState() {
super.initState();
devicesFuture = audioPlayer.devices;
devicesStream = audioPlayer.devicesStream;
selectedDeviceFuture = audioPlayer.selectedDevice;
selectedDeviceStream = audioPlayer.selectedDeviceStream;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Devices',
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: StreamBuilder<List<AudioDevice>>(
stream: devicesStream,
builder: (context, devicesSnapshot) {
return FutureBuilder<List<AudioDevice>>(
future: devicesFuture,
builder: (context, devicesFutureSnapshot) {
final devices =
devicesSnapshot.data ?? devicesFutureSnapshot.data;
return StreamBuilder<AudioDevice>(
stream: selectedDeviceStream,
builder: (context, selectedDeviceSnapshot) {
return FutureBuilder<AudioDevice>(
future: selectedDeviceFuture,
builder: (context, selectedDeviceFutureSnapshot) {
final selectedDevice = selectedDeviceSnapshot.data ??
selectedDeviceFutureSnapshot.data;
if (devices == null || selectedDevice == null) {
return const CircularProgressIndicator();
}
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, idx) {
final device = devices[idx];
return ListTile(
leading: const Icon(Icons.speaker),
title: Text(device.description),
subtitle: Text(device.name),
selected: selectedDevice == device,
onTap: () => audioPlayer.setAudioDevice(device),
);
},
);
},
);
},
);
},
);
},
),
),
],
);
}
}

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

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