Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a60cabca5 | ||
|
|
e10be527cb | ||
|
|
653c1f5563 | ||
|
|
708ab91ccb | ||
|
|
a927e3dd44 | ||
|
|
b6e2340aae | ||
|
|
eea8e46a90 | ||
|
|
fc9a630dd0 | ||
|
|
dbbf215ba8 | ||
|
|
7d701c5dfc | ||
|
|
83ab47d8fc | ||
|
|
6ca31c37ba | ||
|
|
632713d133 | ||
|
|
9a932e92d4 | ||
|
|
5a1910bee4 | ||
|
|
6cb96e95dc | ||
|
|
432e3b1260 | ||
|
|
7bf8d18f18 | ||
|
|
f793223311 | ||
|
|
e89bd6381a | ||
|
|
e3f72a69a9 | ||
|
|
1c57d59d73 | ||
|
|
6351ef1395 | ||
|
|
abe960cf58 | ||
|
|
7cd1e13a82 | ||
|
|
70227afea5 | ||
|
|
e010a9c0d3 | ||
|
|
1604f296c8 | ||
|
|
888ea3fd54 | ||
|
|
30e68780f4 | ||
|
|
5c4b0c2728 | ||
|
|
b439d1cb8c | ||
|
|
fdca721250 | ||
|
|
ca4546871c | ||
|
|
672a545c38 | ||
|
|
d3ed871626 | ||
|
|
c1e720311e | ||
|
|
4a8b1f28db | ||
|
|
d07086aaa0 | ||
|
|
0a1ebfa5df | ||
|
|
18308d9d4e | ||
|
|
b8138ad125 | ||
|
|
9f53d1c604 | ||
|
|
98db2321fb | ||
|
|
ff41769c56 | ||
|
|
30111a5f0c | ||
|
|
a5849969d3 | ||
|
|
af895c18e1 | ||
|
|
2d571eaae4 | ||
|
|
111369c20c | ||
|
|
b517c75cdd | ||
|
|
937bcdd63d | ||
|
|
2b33c17aab | ||
|
|
9762569832 | ||
|
|
9e58d52678 | ||
|
|
eab4a12ccc | ||
|
|
46a9add5cb | ||
|
|
87011c7adc | ||
|
|
6445ee94bf | ||
|
|
6f9fe6262d | ||
|
|
2252b72a14 | ||
|
|
083a1612d5 | ||
|
|
b62af9648b | ||
|
|
56c26e2fd5 | ||
|
|
40d4af322b | ||
|
|
1aba004e43 | ||
|
|
cdf77b308d | ||
|
|
200067a3b2 |
+16
@@ -35,3 +35,19 @@ desktop.ini
|
|||||||
/tmp/
|
/tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
|
|
||||||
|
# ---------- 本地构建 / IDE 产物 ----------
|
||||||
|
gradle-build.log
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# ---------- 个人笔记(不上传) ----------
|
||||||
|
bug-todo.txt
|
||||||
|
|
||||||
|
# ---------- 服务器/监控日志(本地参考,不上传) ----------
|
||||||
|
monitor-log-*.csv
|
||||||
|
|
||||||
|
# ---------- 签名材料(绝不入库) ----------
|
||||||
|
android/key.properties
|
||||||
|
android/keystore/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
|
revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,26 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
- platform: ios
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: linux
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: macos
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: web
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: windows
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -8,11 +8,10 @@ GeneratedPluginRegistrant.java
|
|||||||
.cxx/
|
.cxx/
|
||||||
.kotlin
|
.kotlin
|
||||||
/build
|
/build
|
||||||
|
.settings
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
.settings
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正式签名:android/key.properties(不入库)
|
||||||
|
// 文件缺失时自动回退到 debug keystore,保证 clone 下来也能直接构建
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.lxh.qingting_player"
|
namespace = "com.lxh.qingting_player"
|
||||||
compileSdk = 36
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -14,49 +27,65 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.lxh.qingting_player"
|
applicationId = "com.lxh.qingting_player"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||||
|
// flag during build.
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
// 仅打包 arm64-v8a
|
// 与 main 分支保持一致:仅打包 arm64-v8a
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters.addAll(listOf("arm64-v8a"))
|
abiFilters.addAll(listOf("arm64-v8a"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式指定 jniLibs 源目录
|
// AGP 9 不再允许在 AndroidManifest 里写 android:extractNativeLibs,
|
||||||
sourceSets {
|
// 改为在这里声明,效果等价于原先的“强制提取 .so”
|
||||||
getByName("main") {
|
packaging {
|
||||||
jniLibs.srcDirs("src/main/jniLibs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Copy> {
|
|
||||||
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
|
|
||||||
from("src/main/jniLibs")
|
|
||||||
into("$buildDir/intermediates/merged_jni_libs/debug/out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 打包配置
|
|
||||||
packagingOptions {
|
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging = true
|
useLegacyPackaging = true
|
||||||
}
|
}
|
||||||
pickFirsts += "**/*.so"
|
}
|
||||||
|
|
||||||
|
// AGP 9 默认关闭 resValue 生成,这里显式打开(debug 需要 "清听 Dev" 的应用名)
|
||||||
|
buildFeatures {
|
||||||
|
resValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
|
// 测试包与正式包并存安装:com.lxh.qingting_player.dev
|
||||||
|
// 说明:Android 包名每一段必须以字母开头,所以没法直接写 "_dev",
|
||||||
|
// 这里用 ".dev" 后缀,等价于给 debug 打上 dev 标记。
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
resValue("string", "app_name", "清听 Dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
resValue("string", "app_name", "清听")
|
||||||
proguardFiles(
|
// 有 key.properties 就用正式签名,否则回退 debug(仅本机调试用)
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
"proguard-rules.pro"
|
signingConfigs.getByName("release")
|
||||||
)
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-45
@@ -1,45 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# media_kit 核心
|
|
||||||
# ============================================================
|
|
||||||
-keep class com.media_kit.** { *; }
|
|
||||||
-keep class com.alexmercerind.** { *; }
|
|
||||||
-keep class org.mozilla.** { *; }
|
|
||||||
|
|
||||||
# 保留所有 native 方法
|
|
||||||
-keepclasseswithmembernames class * {
|
|
||||||
native <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 保留所有 JNI 相关
|
|
||||||
-keep class * implements java.lang.reflect.Method { *; }
|
|
||||||
-keep class * extends java.lang.reflect.Method { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# audio_service 插件
|
|
||||||
# ============================================================
|
|
||||||
-keep class com.ryanheise.audioservice.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Flutter 核心
|
|
||||||
# ============================================================
|
|
||||||
-keep class io.flutter.** { *; }
|
|
||||||
-keep class io.flutter.plugin.** { *; }
|
|
||||||
-keep class io.flutter.embedding.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 忽略 Google Play Core 缺失类(如果不想添加依赖)
|
|
||||||
# ============================================================
|
|
||||||
-dontwarn com.google.android.play.core.**
|
|
||||||
-keep class com.google.android.play.core.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 保留 Serializable 支持
|
|
||||||
# ============================================================
|
|
||||||
-keepclassmembers class * implements java.io.Serializable {
|
|
||||||
static final long serialVersionUID;
|
|
||||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
|
||||||
private void writeObject(java.io.ObjectOutputStream);
|
|
||||||
private void readObject(java.io.ObjectInputStream);
|
|
||||||
java.lang.Object writeReplace();
|
|
||||||
java.lang.Object readResolve();
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.lxh.qingting_player">
|
<!-- package 由 build.gradle.kts 的 namespace / applicationId 决定;AGP 9 已忽略这里的 package 属性 -->
|
||||||
|
<!-- 权限 -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="清听"
|
android:label="@string/app_name"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback">
|
||||||
android:extractNativeLibs="true"> <!-- ⭐ 强制提取 .so,避免压缩后加载失败 -->
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- ⭐ AudioService(不加 meta-data) -->
|
||||||
<!-- AudioService -->
|
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -44,8 +42,9 @@
|
|||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
<!-- MediaButtonReceiver -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<!-- ⭐ MediaButtonReceiver -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- FileProvider for artwork(已配置,保持不变) -->
|
<!-- ⭐ FileProvider -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
@@ -69,12 +68,15 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
-20
@@ -4,41 +4,54 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
// 必须继承 AudioServiceActivity,而不是 FlutterActivity:
|
||||||
|
// audio_service 0.18.x 会把 Dart 入口跑在一个单独的缓存引擎(audio_service_engine)里。
|
||||||
|
// 若这里用 FlutterActivity,Activity 会自建第二个 FlutterEngine,
|
||||||
|
// main() 因此被跑两遍,产生两个 PlaybackService / 两个 media_kit Player(libmpv 多实例),
|
||||||
|
// 表现为两路声音、通知栏控制的是另一个实例。
|
||||||
|
// AudioServiceActivity 通过 provideFlutterEngine() 复用同一个缓存引擎,保证全局只有一个 Player。
|
||||||
|
class MainActivity : AudioServiceActivity() {
|
||||||
|
|
||||||
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// ⭐ 强制加载 libmediakitandroidhelper.so,触发 JNI_OnLoad,缓存 JavaVM
|
|
||||||
try {
|
|
||||||
System.loadLibrary("mediakitandroidhelper")
|
|
||||||
Log.d("MainActivity", "✅ libmediakitandroidhelper loaded")
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
Log.e("MainActivity", "❌ libmediakitandroidhelper load failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ 可选:预加载 libmpv.so(让系统 linker 也可见,增强兼容性)
|
|
||||||
try {
|
|
||||||
System.loadLibrary("mpv")
|
|
||||||
Log.d("MainActivity", "✅ libmpv loaded")
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
Log.e("MainActivity", "❌ libmpv load failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestAudioFocus()
|
||||||
setHighRefreshRate()
|
setHighRefreshRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||||
|
Log.d("MainActivity", "音频焦点变化: $focusChange")
|
||||||
|
},
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
null,
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d("MainActivity", "✅ 已请求音频焦点")
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
// FileProvider MethodChannel for sharing artwork via content:// URI
|
// FileProvider MethodChannel
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "getContentUri") {
|
if (call.method == "getContentUri") {
|
||||||
@@ -3,6 +3,7 @@ android.useAndroidX=true
|
|||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
# This builtInKotlin flag was added by the Flutter template
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# 本机 Kotlin 守护进程连不上(fallback 编译),且增量缓存易损坏,沿用 main 分支原有的设置
|
||||||
kotlin.incremental=false
|
kotlin.incremental=false
|
||||||
kotlin.parallel.tasks.in.project=false
|
kotlin.parallel.tasks.in.project=false
|
||||||
android.builtInKotlin=false
|
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-all.zip
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-all.zip
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="FacetManager">
|
|
||||||
<facet type="android" name="Android">
|
|
||||||
<configuration>
|
|
||||||
<option name="ALLOW_USER_CONFIGURATION" value="false" />
|
|
||||||
<option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/gen" />
|
|
||||||
<option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/gen" />
|
|
||||||
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/app/src/main/AndroidManifest.xml" />
|
|
||||||
<option name="RES_FOLDER_RELATIVE_PATH" value="/app/src/main/res" />
|
|
||||||
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/app/src/main/assets" />
|
|
||||||
<option name="LIBS_FOLDER_RELATIVE_PATH" value="/app/src/main/libs" />
|
|
||||||
<option name="PROGUARD_LOGS_FOLDER_RELATIVE_PATH" value="/app/src/main/proguard_logs" />
|
|
||||||
</configuration>
|
|
||||||
</facet>
|
|
||||||
</component>
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/app/src/main/java" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/app/src/main/kotlin" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Android API 24 Platform" jdkType="Android SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
<orderEntry type="library" name="Flutter for Android" level="project" />
|
|
||||||
<orderEntry type="library" name="KotlinJavaRuntime" level="project" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.0.1" apply false
|
id("com.android.application") version "9.1.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// lib/audio/player_controller.dart
|
|
||||||
import '../services/playback_service.dart'; // ⭐ 修正路径
|
|
||||||
|
|
||||||
class PlayerController {
|
|
||||||
static final PlayerController _instance = PlayerController._internal();
|
|
||||||
factory PlayerController() => _instance;
|
|
||||||
PlayerController._internal();
|
|
||||||
|
|
||||||
final PlaybackService _playback = PlaybackService();
|
|
||||||
|
|
||||||
// ---- 状态流(只读) ----
|
|
||||||
Stream<bool> get playingStream => _playback.player.stream.playing;
|
|
||||||
Stream<Duration> get positionStream => _playback.player.stream.position;
|
|
||||||
Stream<Duration> get durationStream => _playback.player.stream.duration;
|
|
||||||
Stream<void> get completedStream => _playback.player.stream.completed;
|
|
||||||
|
|
||||||
// ---- 当前状态快照 ----
|
|
||||||
bool get isPlaying => _playback.player.state.playing;
|
|
||||||
Duration get position => _playback.player.state.position;
|
|
||||||
Duration get duration => _playback.player.state.duration;
|
|
||||||
|
|
||||||
// ---- 控制命令 ----
|
|
||||||
Future<void> play() => _playback.resume();
|
|
||||||
Future<void> pause() => _playback.pause();
|
|
||||||
Future<void> stop() => _playback.stop();
|
|
||||||
Future<void> seek(Duration position) => _playback.seek(position);
|
|
||||||
|
|
||||||
// ---- 加载歌曲 ----
|
|
||||||
void load(String url, {Map<String, String>? headers}) {
|
|
||||||
_playback.play(url, headers: headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// lib/constants/ui_constants.dart
|
// lib/constants/ui_constants.dart
|
||||||
class UIConstants {
|
class UIConstants {
|
||||||
static const double miniPlayerHeight = 60;
|
static const double miniPlayerHeight = 60;
|
||||||
static const double miniPlayerBottomSpace = 80; // 60 + 20 安全余量
|
static const double miniPlayerBottomSpace = 100; // 迷你播放器 60 + 手势区/安全余量
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,14 +92,27 @@ class PlaybackController {
|
|||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 单曲播放(fallback)
|
// 单曲播放(fallback)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
/// 播放路径/文件名是百分号编码的,展示前必须解码
|
||||||
|
String _displayName(String pathOrName) {
|
||||||
|
final raw = pathOrName.split('/').last;
|
||||||
|
String decoded;
|
||||||
|
try {
|
||||||
|
decoded = Uri.decodeComponent(raw);
|
||||||
|
} catch (_) {
|
||||||
|
decoded = raw;
|
||||||
|
}
|
||||||
|
return decoded.replaceAll(RegExp(r'\.[^.]*$'), '');
|
||||||
|
}
|
||||||
Future<void> _playSingleSong(String songPath) async {
|
Future<void> _playSingleSong(String songPath) async {
|
||||||
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
|
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
|
||||||
final url = WebDAVService.instance.getFileUrl(songPath);
|
final url = WebDAVService.instance.getFileUrl(songPath);
|
||||||
final dbSong = await _db.getSongByPath(songPath);
|
final dbSong = await _db.getSongByPath(songPath);
|
||||||
final song = Song(
|
final song = Song(
|
||||||
id: songPath,
|
id: songPath,
|
||||||
title: dbSong?['title'] as String? ??
|
artworkPath: dbSong?['artwork_path'] as String?,
|
||||||
songPath.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: (dbSong?['title'] as String?)?.trim().isNotEmpty == true
|
||||||
|
? dbSong!['title'] as String
|
||||||
|
: _displayName(songPath),
|
||||||
artist: dbSong?['artist'] as String? ?? '未知艺术家',
|
artist: dbSong?['artist'] as String? ?? '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
);
|
);
|
||||||
@@ -117,15 +130,17 @@ class PlaybackController {
|
|||||||
if (dbSong != null) {
|
if (dbSong != null) {
|
||||||
queue.add(Song(
|
queue.add(Song(
|
||||||
id: item.path,
|
id: item.path,
|
||||||
title: dbSong['title'] as String? ??
|
artworkPath: dbSong['artwork_path'] as String?,
|
||||||
item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: (dbSong['title'] as String?)?.trim().isNotEmpty == true
|
||||||
|
? dbSong['title'] as String
|
||||||
|
: _displayName(item.path),
|
||||||
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
queue.add(Song(
|
queue.add(Song(
|
||||||
id: item.path,
|
id: item.path,
|
||||||
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: _displayName(item.path),
|
||||||
artist: '未知艺术家',
|
artist: '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class SongDatabase {
|
|||||||
_log('📂 数据库路径: $path');
|
_log('📂 数据库路径: $path');
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 4, // 升级版本号
|
version: 6, // v6:song_key 改为纯 URL 的 md5(配合分阶段探测)
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -226,6 +226,14 @@ class SongDatabase {
|
|||||||
await _createPlaybackStateTable(db);
|
await _createPlaybackStateTable(db);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
|
||||||
|
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
|
||||||
|
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
await db.delete('metadata_cache');
|
||||||
|
await db.delete('songs');
|
||||||
|
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
|
||||||
|
}
|
||||||
_log('✅ 数据库升级完成');
|
_log('✅ 数据库升级完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +256,33 @@ class SongDatabase {
|
|||||||
return result.isNotEmpty ? result.first : null;
|
return result.isNotEmpty ? result.first : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 批量按路径查元数据,返回 path -> row。
|
||||||
|
/// 文件列表页一次要显示几十上百首,逐条查会太慢。
|
||||||
|
Future<Map<String, Map<String, dynamic>>> getSongsByPaths(
|
||||||
|
List<String> paths) async {
|
||||||
|
final result = <String, Map<String, dynamic>>{};
|
||||||
|
if (paths.isEmpty) return result;
|
||||||
|
|
||||||
|
final db = await database;
|
||||||
|
const chunkSize = 400; // 避免 SQLite 变量数量上限
|
||||||
|
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||||
|
final end = (i + chunkSize) > paths.length ? paths.length : i + chunkSize;
|
||||||
|
final slice = paths.sublist(i, end);
|
||||||
|
final placeholders = List.filled(slice.length, '?').join(',');
|
||||||
|
final rows = await db.query(
|
||||||
|
'songs',
|
||||||
|
where: 'remote_path IN ($placeholders)',
|
||||||
|
whereArgs: slice,
|
||||||
|
);
|
||||||
|
for (final row in rows) {
|
||||||
|
final path = row['remote_path'] as String?;
|
||||||
|
if (path != null) result[path] = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logQuery('songs', 'getByPaths', args: {'count': result.length});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> getSongByHash(String contentHash) async {
|
Future<Map<String, dynamic>?> getSongByHash(String contentHash) async {
|
||||||
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
|
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -278,6 +313,26 @@ class SongDatabase {
|
|||||||
return await db.query('playlists', orderBy: 'created_at DESC');
|
return await db.query('playlists', orderBy: 'created_at DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。
|
||||||
|
/// 列表页只需要这些,不用把每首歌都拉出来。
|
||||||
|
Future<List<Map<String, dynamic>>> getPlaylistSummaries() async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.rawQuery('''
|
||||||
|
SELECT p.id, p.name, p.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM playlist_songs ps
|
||||||
|
WHERE ps.playlist_id = p.id) AS song_count,
|
||||||
|
(SELECT s.artwork_path FROM playlist_songs ps2
|
||||||
|
JOIN songs s ON s.remote_path = ps2.song_id
|
||||||
|
WHERE ps2.playlist_id = p.id
|
||||||
|
AND s.artwork_path IS NOT NULL
|
||||||
|
AND s.artwork_path != ''
|
||||||
|
ORDER BY ps2.order_index
|
||||||
|
LIMIT 1) AS cover_path
|
||||||
|
FROM playlists p
|
||||||
|
ORDER BY p.updated_at DESC
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
||||||
_logQuery('playlists', 'get', args: {'id': id});
|
_logQuery('playlists', 'get', args: {'id': id});
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|||||||
+16
-26
@@ -5,7 +5,9 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'services/audio_service.dart';
|
import 'services/audio_service.dart';
|
||||||
|
import 'services/route_manager.dart';
|
||||||
import 'services/playback_service.dart';
|
import 'services/playback_service.dart';
|
||||||
import 'services/app_lifecycle_service.dart';
|
import 'services/app_lifecycle_service.dart';
|
||||||
import 'services/webdav_service.dart';
|
import 'services/webdav_service.dart';
|
||||||
@@ -15,8 +17,8 @@ import 'pages/home_page.dart';
|
|||||||
import 'pages/player_page.dart';
|
import 'pages/player_page.dart';
|
||||||
import 'pages/playlist_page.dart';
|
import 'pages/playlist_page.dart';
|
||||||
import 'widgets/global_mini_player.dart';
|
import 'widgets/global_mini_player.dart';
|
||||||
|
import 'utils/navigation.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
final stopwatch = Stopwatch();
|
final stopwatch = Stopwatch();
|
||||||
|
|
||||||
bool _appInitialized = false;
|
bool _appInitialized = false;
|
||||||
@@ -53,6 +55,8 @@ void main() async {
|
|||||||
try {
|
try {
|
||||||
_audioHandler = AudioPlayerHandler();
|
_audioHandler = AudioPlayerHandler();
|
||||||
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
|
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
|
||||||
|
// ⭐ 将 Handler 注入到 AudioService
|
||||||
|
AudioService().setHandler(_audioHandler!);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ AudioPlayerHandler failed: $e');
|
debugPrint('❌ AudioPlayerHandler failed: $e');
|
||||||
debugPrint('$st');
|
debugPrint('$st');
|
||||||
@@ -104,28 +108,6 @@ void main() async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 路由管理器(控制 MiniPlayer 显示/隐藏)
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
class RouteManager extends ChangeNotifier {
|
|
||||||
static final RouteManager _instance = RouteManager._internal();
|
|
||||||
factory RouteManager() => _instance;
|
|
||||||
RouteManager._internal();
|
|
||||||
|
|
||||||
String _currentRoute = '/';
|
|
||||||
String get currentRoute => _currentRoute;
|
|
||||||
|
|
||||||
bool get showMiniPlayer =>
|
|
||||||
_currentRoute != '/player' && _currentRoute != '/playlist';
|
|
||||||
|
|
||||||
void updateRoute(String route) {
|
|
||||||
if (_currentRoute != route) {
|
|
||||||
_currentRoute = route;
|
|
||||||
debugPrint('📌 路由更新: $route');
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 导航观察者(监听路由变化)
|
// 导航观察者(监听路由变化)
|
||||||
@@ -363,7 +345,15 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
return MaterialApp(
|
// ⭐ 使用 AnnotatedRegion 包裹 MaterialApp,统一控制系统栏样式
|
||||||
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
|
value: SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: Brightness.light,
|
||||||
|
systemNavigationBarColor: const Color(0xFF1A1F1E),
|
||||||
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
|
),
|
||||||
|
child: MaterialApp(
|
||||||
title: '清听',
|
title: '清听',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
@@ -388,8 +378,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
animation: RouteManager(),
|
animation: RouteManager(),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final routeManager = RouteManager();
|
final routeManager = RouteManager();
|
||||||
final shouldShow = routeManager.currentRoute != '/player' &&
|
final shouldShow = routeManager.showMiniPlayer;
|
||||||
routeManager.currentRoute != '/playlist';
|
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -422,6 +411,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,128 @@
|
|||||||
// lib/metadata/file_provider.dart
|
// lib/metadata/file_provider.dart
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart'; // ⭐ 添加
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
|
import '../utils/song_key.dart';
|
||||||
|
|
||||||
abstract class FileProvider {
|
abstract class FileProvider {
|
||||||
Future<File?> getFile(String url);
|
/// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。
|
||||||
|
Future<File?> getFile(String url, {int maxBytes = 512 * 1024});
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebDAVFileProvider implements FileProvider {
|
class WebDAVFileProvider implements FileProvider {
|
||||||
|
/// 只为读标签取文件头部,绝不整文件下载。
|
||||||
|
/// 之前用 dio.download 会把整首歌拉到临时目录,和 mpv 的流式播放抢带宽,
|
||||||
|
/// 20Mbps 的 FRP 链路上尤其明显(表现为流量很高但缓冲一直为 0)。
|
||||||
|
///
|
||||||
|
/// 从访问日志看:2MB 的探测请求在这条链路上要 10~18 秒,
|
||||||
|
/// 期间 libmpv 的 206 吞吐会被压到 1Mbps 左右,所以压到 512KB。
|
||||||
|
/// 常见标签 + 封面都在这范围内;读不到时由上层在 80% 处重试。
|
||||||
|
static const int defaultMaxBytes = 512 * 1024;
|
||||||
|
|
||||||
|
/// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。
|
||||||
|
static CancelToken? _activeToken;
|
||||||
|
|
||||||
final Dio _dio = Dio();
|
final Dio _dio = Dio();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<File?> getFile(String url) async {
|
Future<File?> getFile(String url, {int maxBytes = defaultMaxBytes}) async {
|
||||||
try {
|
try {
|
||||||
// 1. 获取缓存目录
|
|
||||||
final cacheDir = await getTemporaryDirectory();
|
final cacheDir = await getTemporaryDirectory();
|
||||||
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
final cachePath =
|
||||||
|
'${cacheDir.path}/metadata_${songKeyForUrl(url)}_${maxBytes ~/ 1024}k.part';
|
||||||
final cacheFile = File(cachePath);
|
final cacheFile = File(cachePath);
|
||||||
|
|
||||||
// 2. 如果缓存存在且未过期,直接返回
|
// 1. 命中本地缓存(头部文件同样按 24h 过期)
|
||||||
if (await cacheFile.exists()) {
|
if (await cacheFile.exists()) {
|
||||||
final stat = await cacheFile.stat();
|
final stat = await cacheFile.stat();
|
||||||
// 缓存 1 小时内有效(86400 秒)
|
if (stat.size > 0 &&
|
||||||
if (DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
||||||
return cacheFile;
|
return cacheFile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 获取认证头
|
// 2. 认证头
|
||||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
if (headers.isEmpty) return null;
|
if (headers.isEmpty) return null;
|
||||||
|
|
||||||
// 4. 只下载前 1MB
|
// 3. 取消上一次未完成的下载
|
||||||
await _dio.download(
|
_activeToken?.cancel('superseded');
|
||||||
|
final token = CancelToken();
|
||||||
|
_activeToken = token;
|
||||||
|
|
||||||
|
// 4. 只请求前 2MB
|
||||||
|
final response = await _dio.get<ResponseBody>(
|
||||||
url,
|
url,
|
||||||
cachePath,
|
cancelToken: token,
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: headers,
|
headers: {
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
...headers,
|
||||||
|
'Range': 'bytes=0-${maxBytes - 1}',
|
||||||
|
},
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
receiveTimeout: const Duration(seconds: 15),
|
||||||
sendTimeout: const Duration(seconds: 10),
|
sendTimeout: const Duration(seconds: 10),
|
||||||
|
followRedirects: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final body = response.data;
|
||||||
|
if (body == null) return null;
|
||||||
|
|
||||||
|
// 5. 写入临时文件(最多 _maxBytes)
|
||||||
|
final sink = cacheFile.openWrite();
|
||||||
|
var written = 0;
|
||||||
|
try {
|
||||||
|
await for (final chunk in body.stream) {
|
||||||
|
final remain = maxBytes - written;
|
||||||
|
if (remain <= 0) break;
|
||||||
|
final data =
|
||||||
|
chunk.lengthInBytes > remain ? chunk.sublist(0, remain) : chunk;
|
||||||
|
sink.add(data);
|
||||||
|
written += data.lengthInBytes;
|
||||||
|
if (written >= maxBytes) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await sink.flush();
|
||||||
|
await sink.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written == 0) {
|
||||||
|
try {
|
||||||
|
await cacheFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'📦 [WebDAVFileProvider] 取头部 ${(written / 1024).toStringAsFixed(0)}KB'
|
||||||
|
'(上限 ${maxBytes ~/ 1024}KB,不再整文件下载)');
|
||||||
return cacheFile;
|
return cacheFile;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (CancelToken.isCancel(e)) {
|
||||||
|
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
||||||
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
|
||||||
|
static void cancelActive() {
|
||||||
|
_activeToken?.cancel('superseded');
|
||||||
|
_activeToken = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalFileProvider implements FileProvider {
|
class LocalFileProvider implements FileProvider {
|
||||||
@override
|
@override
|
||||||
Future<File?> getFile(String url) async {
|
Future<File?> getFile(String url, {int maxBytes = 512 * 1024}) async {
|
||||||
final file = File(url);
|
final file = File(url);
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
return file;
|
return file;
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class NormalizedMetadata {
|
|||||||
final Duration? duration;
|
final Duration? duration;
|
||||||
final Uint8List? artwork;
|
final Uint8List? artwork;
|
||||||
|
|
||||||
|
/// 字段来源:true 表示来自标签,false 表示从文件名/路径推断。
|
||||||
|
/// 置信度模型需要区分这两者——"标签里的标题"和"文件名当标题"价值完全不同。
|
||||||
|
final bool titleFromTag;
|
||||||
|
final bool artistFromTag;
|
||||||
|
final bool albumFromTag;
|
||||||
|
|
||||||
const NormalizedMetadata({
|
const NormalizedMetadata({
|
||||||
this.title = '',
|
this.title = '',
|
||||||
this.artist = '',
|
this.artist = '',
|
||||||
@@ -70,6 +76,9 @@ class NormalizedMetadata {
|
|||||||
this.totalDisc = 0,
|
this.totalDisc = 0,
|
||||||
this.duration,
|
this.duration,
|
||||||
this.artwork,
|
this.artwork,
|
||||||
|
this.titleFromTag = false,
|
||||||
|
this.artistFromTag = false,
|
||||||
|
this.albumFromTag = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isEmpty =>
|
bool get isEmpty =>
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import 'metadata_model.dart';
|
|||||||
class MetadataNormalizer {
|
class MetadataNormalizer {
|
||||||
NormalizedMetadata normalize(RawMetadata raw,
|
NormalizedMetadata normalize(RawMetadata raw,
|
||||||
{String fileName = '', String filePath = ''}) {
|
{String fileName = '', String filePath = ''}) {
|
||||||
final title = raw.title.trim();
|
final tagTitle = raw.title.trim();
|
||||||
final artist = raw.artist.trim();
|
final tagArtist = raw.artist.trim();
|
||||||
|
final tagAlbum = raw.album.trim();
|
||||||
|
|
||||||
final finalTitle =
|
final finalTitle =
|
||||||
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
|
tagTitle.isNotEmpty ? tagTitle : _inferTitleFromFileName(fileName);
|
||||||
final finalArtist =
|
|
||||||
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
|
final artistFromPath = _inferArtistFromPath(filePath);
|
||||||
final finalArtist2 = finalArtist.isNotEmpty
|
final finalArtist = tagArtist.isNotEmpty
|
||||||
? finalArtist
|
? tagArtist
|
||||||
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
|
: (raw.performers.isNotEmpty ? raw.performers.first : artistFromPath);
|
||||||
|
|
||||||
return NormalizedMetadata(
|
return NormalizedMetadata(
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
artist: finalArtist2,
|
artist: finalArtist,
|
||||||
album: raw.album,
|
album: tagAlbum,
|
||||||
genres: raw.genres,
|
genres: raw.genres,
|
||||||
performers: raw.performers,
|
performers: raw.performers,
|
||||||
year: raw.year,
|
year: raw.year,
|
||||||
@@ -28,6 +29,9 @@ class MetadataNormalizer {
|
|||||||
totalDisc: raw.totalDisc,
|
totalDisc: raw.totalDisc,
|
||||||
duration: raw.duration,
|
duration: raw.duration,
|
||||||
artwork: raw.artwork,
|
artwork: raw.artwork,
|
||||||
|
titleFromTag: tagTitle.isNotEmpty,
|
||||||
|
artistFromTag: tagArtist.isNotEmpty || raw.performers.isNotEmpty,
|
||||||
|
albumFromTag: tagAlbum.isNotEmpty,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,16 +53,94 @@ class MetadataNormalizer {
|
|||||||
return '未知艺术家';
|
return '未知艺术家';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 置信度评估(元数据引擎第 2 步)。
|
||||||
|
///
|
||||||
|
/// 设计原则:
|
||||||
|
/// 1. 标签 >= 推断:来自标签的字段永远比文件名/目录推断更可信;
|
||||||
|
/// 2. 字段齐全度:标题、艺术家、专辑依次加分;
|
||||||
|
/// 3. 历史一致性:同一艺人/专辑已有多条可信记录时额外加权,
|
||||||
|
/// 这是"自学习"的入口——库越大,判断越稳;
|
||||||
|
/// 4. 可解释:每个加分项都写进 evidence,方便日志与后续调参。
|
||||||
|
///
|
||||||
|
/// 分数区间大致:
|
||||||
|
/// 0.35 只有文件名
|
||||||
|
/// 0.55 标题来自标签、艺术家靠推断
|
||||||
|
/// 0.80 标题 + 艺术家都来自标签
|
||||||
|
/// 0.90 标题 + 艺术家 + 专辑
|
||||||
|
/// 0.95 以上再加年份/封面/历史一致性
|
||||||
CandidateMetadata evaluate(
|
CandidateMetadata evaluate(
|
||||||
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
|
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
|
||||||
// 第一版:直接接受,置信度 0.8
|
final evidence = <String, double>{};
|
||||||
|
var confidence = 0.25;
|
||||||
|
evidence['base'] = 0.25;
|
||||||
|
|
||||||
|
if (normalized.title.isNotEmpty) {
|
||||||
|
confidence += 0.20;
|
||||||
|
evidence['title'] = 0.20;
|
||||||
|
}
|
||||||
|
if (normalized.titleFromTag) {
|
||||||
|
confidence += 0.10;
|
||||||
|
evidence['title_from_tag'] = 0.10;
|
||||||
|
} else if (normalized.title.isNotEmpty) {
|
||||||
|
// 拿文件名当标题:明确扣分
|
||||||
|
confidence -= 0.10;
|
||||||
|
evidence['title_inferred_penalty'] = -0.10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.artist.isNotEmpty && normalized.artist != '未知艺术家') {
|
||||||
|
confidence += 0.15;
|
||||||
|
evidence['artist'] = 0.15;
|
||||||
|
}
|
||||||
|
if (normalized.artistFromTag) {
|
||||||
|
confidence += 0.10;
|
||||||
|
evidence['artist_from_tag'] = 0.10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.album.isNotEmpty) {
|
||||||
|
confidence += 0.10;
|
||||||
|
evidence['album'] = 0.10;
|
||||||
|
}
|
||||||
|
if (normalized.year > 0) {
|
||||||
|
confidence += 0.03;
|
||||||
|
evidence['year'] = 0.03;
|
||||||
|
}
|
||||||
|
if (normalized.artwork != null && normalized.artwork!.isNotEmpty) {
|
||||||
|
confidence += 0.02;
|
||||||
|
evidence['artwork'] = 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 历史一致性(自学习的核心)----
|
||||||
|
if (history.isNotEmpty) {
|
||||||
|
final sameArtist = history
|
||||||
|
.where((row) => (row['artist'] as String?) == normalized.artist)
|
||||||
|
.length;
|
||||||
|
if (sameArtist >= 2) {
|
||||||
|
confidence += 0.05;
|
||||||
|
evidence['artist_history'] = 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.album.isNotEmpty) {
|
||||||
|
final sameAlbum = history
|
||||||
|
.where((row) => (row['album'] as String?) == normalized.album)
|
||||||
|
.length;
|
||||||
|
if (sameAlbum >= 2) {
|
||||||
|
confidence += 0.03;
|
||||||
|
evidence['album_history'] = 0.03;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence = confidence.clamp(0.05, 0.98);
|
||||||
return CandidateMetadata(
|
return CandidateMetadata(
|
||||||
metadata: normalized,
|
metadata: normalized,
|
||||||
confidence: 0.8,
|
confidence: double.parse(confidence.toStringAsFixed(2)),
|
||||||
evidence: {'normalized': 0.8},
|
evidence: evidence,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按阈值决定最终元数据。
|
||||||
|
/// 低于阈值时仍返回数据(用于展示),但 source 标成 pending,
|
||||||
|
/// 调用方据此累计 validation_count、下次播放重新确认。
|
||||||
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
|
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
|
||||||
return FinalMetadata(
|
return FinalMetadata(
|
||||||
title: candidate.metadata.title,
|
title: candidate.metadata.title,
|
||||||
@@ -76,7 +158,7 @@ class MetadataNormalizer {
|
|||||||
totalDisc: candidate.metadata.totalDisc,
|
totalDisc: candidate.metadata.totalDisc,
|
||||||
duration: candidate.metadata.duration,
|
duration: candidate.metadata.duration,
|
||||||
artwork: candidate.metadata.artwork,
|
artwork: candidate.metadata.artwork,
|
||||||
source: 'normalized',
|
source: candidate.confidence >= threshold ? 'normalized' : 'pending',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import '../database/song_database.dart';
|
|||||||
import 'metadata_model.dart';
|
import 'metadata_model.dart';
|
||||||
import 'metadata_reader.dart';
|
import 'metadata_reader.dart';
|
||||||
import 'metadata_normalizer.dart';
|
import 'metadata_normalizer.dart';
|
||||||
|
import 'metadata_stability.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
import 'metadata_cache.dart';
|
import 'metadata_cache.dart';
|
||||||
import 'file_provider.dart';
|
import 'file_provider.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
|
import '../utils/song_key.dart';
|
||||||
|
|
||||||
class MetadataService {
|
class MetadataService {
|
||||||
static final MetadataService _instance = MetadataService._internal();
|
static final MetadataService _instance = MetadataService._internal();
|
||||||
@@ -20,9 +23,8 @@ class MetadataService {
|
|||||||
final MetadataCache _memoryCache = MetadataCache();
|
final MetadataCache _memoryCache = MetadataCache();
|
||||||
final SongDatabase _db = SongDatabase();
|
final SongDatabase _db = SongDatabase();
|
||||||
|
|
||||||
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
String _generateSongKey(String url) {
|
||||||
final raw = '$url|$fileSize|$modifiedTime';
|
return songKeyFor(url);
|
||||||
return raw.hashCode.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FileProvider _getProvider(String url) {
|
FileProvider _getProvider(String url) {
|
||||||
@@ -58,25 +60,39 @@ class MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取文件
|
// 2. 分阶段取头部:先 128KB 拿 title/artist,读不到再补到 512KB。
|
||||||
|
// 弱网下首包更小,标签能更快出来,也少占带宽。
|
||||||
final provider = _getProvider(url);
|
final provider = _getProvider(url);
|
||||||
final file = await provider.getFile(url);
|
var file = await provider.getFile(url, maxBytes: 128 * 1024);
|
||||||
|
|
||||||
if (file == null || !await file.exists()) {
|
if (file == null || !await file.exists()) {
|
||||||
debugPrint('⚠️ [MetadataService] file not available, using fallback');
|
debugPrint('⚠️ [MetadataService] file not available, using fallback');
|
||||||
return _fallbackFromFileName(fileName);
|
return _fallbackFromFileName(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
final stat = await file.stat();
|
var raw = await _reader.readRawMetadata(file);
|
||||||
final songKey =
|
if (raw.title.isEmpty && raw.artist.isEmpty) {
|
||||||
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
|
debugPrint('📦 [MetadataService] 128KB 没读到标签,补取到 512KB');
|
||||||
|
final bigger = await provider.getFile(url, maxBytes: 512 * 1024);
|
||||||
|
if (bigger != null && await bigger.exists()) {
|
||||||
|
file = bigger;
|
||||||
|
raw = await _reader.readRawMetadata(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. SQLite 查询(高置信度直接使用)
|
final stat = await file.stat();
|
||||||
|
final songKey = _generateSongKey(url);
|
||||||
|
|
||||||
|
// 3. SQLite 查询:只有「已验证」的记录才直接返回。
|
||||||
|
// pending 记录会继续往下走,重新读取并累计 validation_count,
|
||||||
|
// 这就是自修复:置信度不足的记录会在下次播放时被重新确认。
|
||||||
|
Map<String, dynamic>? dbSong;
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
final dbSong = await _db.getSong(songKey);
|
dbSong = await _db.getSong(songKey);
|
||||||
if (dbSong != null) {
|
if (dbSong != null) {
|
||||||
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
|
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||||
if (confidence >= 0.6) {
|
final status = dbSong['metadata_status'] as String? ?? 'pending';
|
||||||
|
if (confidence >= 0.6 && status == 'verified') {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
|
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
|
||||||
|
|
||||||
@@ -103,59 +119,105 @@ class MetadataService {
|
|||||||
source: 'database',
|
source: 'database',
|
||||||
);
|
);
|
||||||
await _memoryCache.put(fileId, metadata);
|
await _memoryCache.put(fileId, metadata);
|
||||||
|
// 命中也推一次:列表页可能是元数据写入前加载的,
|
||||||
|
// 靠这次事件把那一行补上(幂等,订阅方按 path 覆盖)。
|
||||||
|
MetadataEvents().publish(fileId, dbSong);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
debugPrint('🔁 [MetadataService] 记录未验证'
|
||||||
|
'(conf=${confidence.toStringAsFixed(2)}, status=$status),重新确认');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 读取原始 metadata
|
// 4. 标准化(raw 已在上面分阶段读取)
|
||||||
final raw = await _reader.readRawMetadata(file);
|
|
||||||
|
|
||||||
// 5. 标准化
|
|
||||||
final normalized = _normalizer.normalize(
|
final normalized = _normalizer.normalize(
|
||||||
raw,
|
raw,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
filePath: url,
|
filePath: url,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. 评估
|
// 6. 评估(含历史一致性)
|
||||||
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
|
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
|
||||||
final candidate = _normalizer.evaluate(normalized, history);
|
final candidate = _normalizer.evaluate(normalized, history);
|
||||||
final finalMetadata = _normalizer.decide(candidate);
|
final finalMetadata = _normalizer.decide(candidate);
|
||||||
debugPrint(
|
|
||||||
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
|
|
||||||
|
|
||||||
// 7. 保存封面图到本地
|
// 7. 与已有记录合并(一致性 + 次数由状态机决定)
|
||||||
String? artworkPath;
|
final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||||
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
|
final decision = MetadataStability.decide(
|
||||||
|
previous: dbSong,
|
||||||
|
candidate: candidate,
|
||||||
|
title: finalMetadata.title,
|
||||||
|
artist: finalMetadata.artist,
|
||||||
|
);
|
||||||
|
final validationCount = decision.validationCount;
|
||||||
|
final useNew = dbSong == null || candidate.confidence >= prevConfidence;
|
||||||
|
final effectiveConfidence =
|
||||||
|
useNew ? candidate.confidence : prevConfidence;
|
||||||
|
|
||||||
|
// 8. 保存封面图(只有采用新结果且确实读到图时才写)
|
||||||
|
String? artworkPath = dbSong?['artwork_path'] as String?;
|
||||||
|
if (useNew &&
|
||||||
|
finalMetadata.artwork != null &&
|
||||||
|
finalMetadata.artwork!.isNotEmpty) {
|
||||||
artworkPath =
|
artworkPath =
|
||||||
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
|
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 保存到 SQLite
|
final status = decision.status;
|
||||||
await _db.insertSong({
|
debugPrint('🧭 [MetadataService] 状态机: ${decision.reason} → $status'
|
||||||
|
'(validations=$validationCount)');
|
||||||
|
|
||||||
|
final row = <String, dynamic>{
|
||||||
'song_key': songKey,
|
'song_key': songKey,
|
||||||
'remote_path': url,
|
// remote_path 必须存应用内路径(fileId / song.id),不能存完整 URL
|
||||||
|
'remote_path': fileId,
|
||||||
'file_size': stat.size,
|
'file_size': stat.size,
|
||||||
'modified_time': stat.modified.millisecondsSinceEpoch,
|
'modified_time': stat.modified.millisecondsSinceEpoch,
|
||||||
'title': finalMetadata.title,
|
'title': useNew ? finalMetadata.title : (dbSong?['title'] ?? finalMetadata.title),
|
||||||
'artist': finalMetadata.artist,
|
'artist':
|
||||||
'album': finalMetadata.album,
|
useNew ? finalMetadata.artist : (dbSong?['artist'] ?? finalMetadata.artist),
|
||||||
'genre': finalMetadata.genre,
|
'album': useNew ? finalMetadata.album : (dbSong?['album'] ?? finalMetadata.album),
|
||||||
|
'genre': useNew ? finalMetadata.genre : (dbSong?['genre'] ?? finalMetadata.genre),
|
||||||
'artwork_path': artworkPath,
|
'artwork_path': artworkPath,
|
||||||
'confidence': candidate.confidence,
|
'confidence': effectiveConfidence,
|
||||||
'validation_count': 1,
|
'validation_count': validationCount,
|
||||||
'first_scan': DateTime.now().millisecondsSinceEpoch,
|
'first_scan':
|
||||||
|
dbSong?['first_scan'] ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
'last_scan': DateTime.now().millisecondsSinceEpoch,
|
'last_scan': DateTime.now().millisecondsSinceEpoch,
|
||||||
'metadata_status': candidate.confidence >= 0.6 ? 'verified' : 'pending',
|
'metadata_status': status,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (dbSong == null) {
|
||||||
|
await _db.insertSong(row);
|
||||||
|
} else {
|
||||||
|
await _db.updateSong(songKey, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知界面局部刷新(收藏/文件列表/歌单等)
|
||||||
|
MetadataEvents().publish(fileId, row);
|
||||||
|
|
||||||
|
final merged = FinalMetadata(
|
||||||
|
title: row['title'] as String? ?? '',
|
||||||
|
artist: row['artist'] as String? ?? '',
|
||||||
|
album: row['album'] as String? ?? '',
|
||||||
|
genre: row['genre'] as String? ?? '',
|
||||||
|
genres: finalMetadata.genres,
|
||||||
|
performers: finalMetadata.performers,
|
||||||
|
year: finalMetadata.year,
|
||||||
|
duration: finalMetadata.duration,
|
||||||
|
artwork: finalMetadata.artwork,
|
||||||
|
source: status,
|
||||||
|
);
|
||||||
|
|
||||||
// 9. 内存缓存
|
// 9. 内存缓存
|
||||||
await _memoryCache.put(fileId, finalMetadata);
|
await _memoryCache.put(fileId, merged);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint('📊 [MetadataService] $status '
|
||||||
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})');
|
'conf=${effectiveConfidence.toStringAsFixed(2)} '
|
||||||
return finalMetadata;
|
'validations=$validationCount '
|
||||||
|
'evidence=${candidate.evidence}');
|
||||||
|
debugPrint('✅ [MetadataService] saved: ${merged.title} - ${merged.artist}');
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
FinalMetadata _fallbackFromFileName(String fileName) {
|
FinalMetadata _fallbackFromFileName(String fileName) {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// lib/metadata/metadata_stability.dart
|
||||||
|
import 'metadata_model.dart';
|
||||||
|
|
||||||
|
/// 状态机判定结果
|
||||||
|
class StabilityDecision {
|
||||||
|
/// 'verified' | 'pending'
|
||||||
|
final String status;
|
||||||
|
final String reason;
|
||||||
|
final int validationCount;
|
||||||
|
|
||||||
|
const StabilityDecision({
|
||||||
|
required this.status,
|
||||||
|
required this.reason,
|
||||||
|
required this.validationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isVerified => status == 'verified';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据状态机:决定一条记录能不能升级为 verified。
|
||||||
|
///
|
||||||
|
/// 第 2 步的规则是"置信度 >= 0.6 且验证次数 >= 2",太粗暴:
|
||||||
|
/// 两次读到的是不同结果也会被当成"验证通过"。这里加入一致性检查——
|
||||||
|
///
|
||||||
|
/// - 首次见到 → pending(需要第二次确认)
|
||||||
|
/// - 与上次记录冲突(标题或艺术家明显不同)→ 次数重置为 1,继续 pending
|
||||||
|
/// - 一致且次数 >= 2、置信度达标 → verified
|
||||||
|
/// - 置信度不足 → pending
|
||||||
|
///
|
||||||
|
/// 这样一次误读不会把错误标签固化,下次播放会重新校正。
|
||||||
|
class MetadataStability {
|
||||||
|
static const double threshold = 0.6;
|
||||||
|
|
||||||
|
static StabilityDecision decide({
|
||||||
|
required Map<String, dynamic>? previous,
|
||||||
|
required CandidateMetadata candidate,
|
||||||
|
required String title,
|
||||||
|
required String artist,
|
||||||
|
}) {
|
||||||
|
if (previous == null) {
|
||||||
|
return const StabilityDecision(
|
||||||
|
status: 'pending',
|
||||||
|
reason: 'first_seen',
|
||||||
|
validationCount: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final prevConfidence =
|
||||||
|
(previous['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final prevTitle = previous['title'] as String? ?? '';
|
||||||
|
final prevArtist = previous['artist'] as String? ?? '';
|
||||||
|
final prevCount = (previous['validation_count'] as int?) ?? 0;
|
||||||
|
|
||||||
|
final consistent = _similar(prevTitle, title) && _similar(prevArtist, artist);
|
||||||
|
if (!consistent) {
|
||||||
|
return const StabilityDecision(
|
||||||
|
status: 'pending',
|
||||||
|
reason: 'conflict',
|
||||||
|
validationCount: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final count = prevCount + 1;
|
||||||
|
final best = candidate.confidence > prevConfidence
|
||||||
|
? candidate.confidence
|
||||||
|
: prevConfidence;
|
||||||
|
|
||||||
|
if (best < threshold) {
|
||||||
|
return StabilityDecision(
|
||||||
|
status: 'pending',
|
||||||
|
reason: 'low_confidence',
|
||||||
|
validationCount: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < 2) {
|
||||||
|
return StabilityDecision(
|
||||||
|
status: 'pending',
|
||||||
|
reason: 'need_second_validation',
|
||||||
|
validationCount: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StabilityDecision(
|
||||||
|
status: 'verified',
|
||||||
|
reason: 'stable',
|
||||||
|
validationCount: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字段缺失不算冲突;只有两边都有值且不同才算。
|
||||||
|
static bool _similar(String a, String b) {
|
||||||
|
final left = a.trim().toLowerCase();
|
||||||
|
final right = b.trim().toLowerCase();
|
||||||
|
if (left.isEmpty || right.isEmpty) return true;
|
||||||
|
return left == right;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// lib/models/playlist_summary.dart
|
||||||
|
|
||||||
|
/// 歌单列表用的轻量摘要(不加载歌单内全部歌曲)
|
||||||
|
class PlaylistSummary {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int songCount;
|
||||||
|
|
||||||
|
/// 封面:取歌单里第一首有封面的歌
|
||||||
|
final String? coverPath;
|
||||||
|
final int updatedAt;
|
||||||
|
|
||||||
|
const PlaylistSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.songCount = 0,
|
||||||
|
this.coverPath,
|
||||||
|
this.updatedAt = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PlaylistSummary.fromRow(Map<String, dynamic> row) {
|
||||||
|
final cover = (row['cover_path'] as String?)?.trim() ?? '';
|
||||||
|
return PlaylistSummary(
|
||||||
|
id: row['id'] as String? ?? '',
|
||||||
|
name: (row['name'] as String?)?.trim() ?? '',
|
||||||
|
songCount: (row['song_count'] as num?)?.toInt() ?? 0,
|
||||||
|
coverPath: cover.isEmpty ? null : cover,
|
||||||
|
updatedAt: (row['updated_at'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEmpty => songCount == 0;
|
||||||
|
}
|
||||||
+424
-166
@@ -1,10 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
|
import '../services/playlist_events.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import '../services/playback_service.dart';
|
|
||||||
import '../widgets/magnetic_scroll_physics.dart';
|
import '../widgets/magnetic_scroll_physics.dart';
|
||||||
import 'webdav_setup_page.dart';
|
import 'webdav_setup_page.dart';
|
||||||
|
import 'settings_page.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import 'playlist_detail_page.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
import 'webdav_file_list_page.dart';
|
import 'webdav_file_list_page.dart';
|
||||||
import '../constants/ui_constants.dart';
|
import '../constants/ui_constants.dart';
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
@@ -17,6 +26,7 @@ class SongItem {
|
|||||||
final String fileName;
|
final String fileName;
|
||||||
final String? title;
|
final String? title;
|
||||||
final String? artist;
|
final String? artist;
|
||||||
|
final String? artworkPath;
|
||||||
final String sourceTag;
|
final String sourceTag;
|
||||||
final String metadataState;
|
final String metadataState;
|
||||||
|
|
||||||
@@ -25,6 +35,7 @@ class SongItem {
|
|||||||
required this.fileName,
|
required this.fileName,
|
||||||
this.title,
|
this.title,
|
||||||
this.artist,
|
this.artist,
|
||||||
|
this.artworkPath,
|
||||||
required this.sourceTag,
|
required this.sourceTag,
|
||||||
this.metadataState = 'unknown',
|
this.metadataState = 'unknown',
|
||||||
});
|
});
|
||||||
@@ -34,11 +45,10 @@ class SongItem {
|
|||||||
: fileName.replaceAll(RegExp(r'\.[^.]*$'), '');
|
: fileName.replaceAll(RegExp(r'\.[^.]*$'), '');
|
||||||
|
|
||||||
String get displaySubtitle {
|
String get displaySubtitle {
|
||||||
if (metadataState == 'success' && artist != null) {
|
if (metadataState == 'success' && artist != null && artist!.isNotEmpty) {
|
||||||
return artist!;
|
return artist!;
|
||||||
} else {
|
|
||||||
return sourceTag;
|
|
||||||
}
|
}
|
||||||
|
return sourceTag.isNotEmpty ? sourceTag : '未知艺术家';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +164,14 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
List<SongItem> _favorites = [];
|
List<SongItem> _favorites = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
/// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
|
||||||
|
int _homeTab = 0;
|
||||||
|
final PageController _homePageController = PageController();
|
||||||
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
||||||
|
StreamSubscription<String>? _favSub;
|
||||||
|
StreamSubscription<void>? _plSub;
|
||||||
|
List<PlaylistSummary> _playlistSummaries = [];
|
||||||
|
|
||||||
// ========== 滚动控制 ==========
|
// ========== 滚动控制 ==========
|
||||||
late final ScrollController _scrollController;
|
late final ScrollController _scrollController;
|
||||||
final GlobalKey _mediaLibraryKey = GlobalKey();
|
final GlobalKey _mediaLibraryKey = GlobalKey();
|
||||||
@@ -189,6 +207,9 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController = ScrollController()..addListener(_onScroll);
|
_scrollController = ScrollController()..addListener(_onScroll);
|
||||||
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
||||||
|
_favSub = FavoriteEvents().stream.listen((_) => _onFavoriteChanged());
|
||||||
|
_plSub = PlaylistEvents().stream.listen((_) => _onPlaylistChanged());
|
||||||
_initWebDAV();
|
_initWebDAV();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -198,6 +219,10 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_metaSub?.cancel();
|
||||||
|
_favSub?.cancel();
|
||||||
|
_plSub?.cancel();
|
||||||
|
_homePageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -228,6 +253,43 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单数据变化后刷新歌单页
|
||||||
|
Future<void> _onPlaylistChanged() async {
|
||||||
|
await _loadPlaylistSummaries();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任何位置收藏/取消收藏后,重新拉一次收藏列表
|
||||||
|
Future<void> _onFavoriteChanged() async {
|
||||||
|
await _loadFavoritesFromDb();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据补全后,按 path 更新收藏列表里的对应行
|
||||||
|
void _onMetadataUpdate(MetadataUpdate update) {
|
||||||
|
final index = _favorites.indexWhere((item) => item.path == update.path);
|
||||||
|
if (index < 0 || !mounted) return;
|
||||||
|
|
||||||
|
final row = update.row;
|
||||||
|
final title = (row['title'] as String?)?.trim() ?? '';
|
||||||
|
final artist = (row['artist'] as String?)?.trim() ?? '';
|
||||||
|
final artwork = (row['artwork_path'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
|
final old = _favorites[index];
|
||||||
|
safeSetState(() {
|
||||||
|
_favorites[index] = SongItem(
|
||||||
|
path: old.path,
|
||||||
|
fileName: old.fileName,
|
||||||
|
title: title.isNotEmpty ? title : old.title,
|
||||||
|
artist: artist.isNotEmpty ? artist : old.artist,
|
||||||
|
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
|
||||||
|
sourceTag: old.sourceTag,
|
||||||
|
metadataState: (title.isNotEmpty || old.title != null)
|
||||||
|
? 'success'
|
||||||
|
: 'unknown',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (mounted) safeSetState(() {});
|
if (mounted) safeSetState(() {});
|
||||||
}
|
}
|
||||||
@@ -236,7 +298,8 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
safeSetState(() => _isLoading = true);
|
safeSetState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
await WebDAVService.instance.loadCredentials();
|
await WebDAVService.instance.loadCredentials();
|
||||||
_favorites = [];
|
await _loadFavoritesFromDb();
|
||||||
|
await _loadPlaylistSummaries();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_favorites = [];
|
_favorites = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -244,18 +307,65 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收藏列表直接绑定 favorites 表 + songs 元数据
|
||||||
|
Future<void> _loadFavoritesFromDb() async {
|
||||||
|
final db = SongDatabase();
|
||||||
|
final favRows = await db.getFavorites();
|
||||||
|
final paths = favRows
|
||||||
|
.map((row) => row['song_id'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
final meta = await db.getSongsByPaths(paths);
|
||||||
|
|
||||||
|
final items = <SongItem>[];
|
||||||
|
for (final path in paths) {
|
||||||
|
final row = meta[path];
|
||||||
|
final title = (row?['title'] as String?)?.trim() ?? '';
|
||||||
|
final artist = (row?['artist'] as String?)?.trim() ?? '';
|
||||||
|
final hasTitle = title.isNotEmpty;
|
||||||
|
|
||||||
|
// path 是百分号编码的(播放时必须保持),显示前要解码,
|
||||||
|
// 否则没有元数据的收藏会显示成 "13%E6%A5%BC..." 这种不可读串。
|
||||||
|
final rawName = path.split('/').last;
|
||||||
|
String displayName;
|
||||||
|
try {
|
||||||
|
displayName = Uri.decodeComponent(rawName);
|
||||||
|
} catch (_) {
|
||||||
|
displayName = rawName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取上级目录名,作为没有标签时的副标题(加括号区分"这是文件夹")
|
||||||
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
String folderName = '';
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
try {
|
||||||
|
folderName = Uri.decodeComponent(segments[segments.length - 2]);
|
||||||
|
} catch (_) {
|
||||||
|
folderName = segments[segments.length - 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(SongItem(
|
||||||
|
path: path,
|
||||||
|
fileName: displayName,
|
||||||
|
title: hasTitle ? title : null,
|
||||||
|
artist: artist.isNotEmpty ? artist : null,
|
||||||
|
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty == true
|
||||||
|
? (row!['artwork_path'] as String).trim()
|
||||||
|
: null,
|
||||||
|
sourceTag: folderName.isNotEmpty ? '($folderName)' : '',
|
||||||
|
metadataState: hasTitle ? 'success' : 'unknown',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_favorites = items;
|
||||||
|
}
|
||||||
|
|
||||||
void _playSong(SongItem song) async {
|
void _playSong(SongItem song) async {
|
||||||
try {
|
try {
|
||||||
final url = WebDAVService.instance.getFileUrl(song.path);
|
// 先校验登录,再开始播放。
|
||||||
|
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
|
||||||
final audioService = context.read<AudioService>();
|
// 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
|
||||||
audioService.playSong(Song(
|
// 表现就是"媒体加载了但没有声音"。
|
||||||
id: song.path,
|
|
||||||
title: song.displayTitle,
|
|
||||||
artist: song.displaySubtitle,
|
|
||||||
url: url,
|
|
||||||
));
|
|
||||||
|
|
||||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
|
|
||||||
if (headers.isEmpty) {
|
if (headers.isEmpty) {
|
||||||
@@ -270,7 +380,17 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PlaybackService().play(url, headers: headers);
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final url = WebDAVService.instance.getFileUrl(song.path);
|
||||||
|
final audioService = context.read<AudioService>();
|
||||||
|
await audioService.playSong(Song(
|
||||||
|
id: song.path,
|
||||||
|
title: song.displayTitle,
|
||||||
|
artist: song.displaySubtitle,
|
||||||
|
artworkPath: song.artworkPath,
|
||||||
|
url: url,
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -283,6 +403,250 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPlaylistSummaries() async {
|
||||||
|
try {
|
||||||
|
final list = await PlaylistRepository().getSummaries();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _playlistSummaries = list);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Home] 读取歌单失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHomeTabIndicator() {
|
||||||
|
Widget seg(int index) {
|
||||||
|
final active = _homeTab == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => safeSetState(() => _homeTab = index),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: active ? 26 : 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? Colors.white : Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
seg(0),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
seg(1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFavoritesTab() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_favorites.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
20, 0, 20, UIConstants.miniPlayerBottomSpace),
|
||||||
|
itemCount: _favorites.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final song = _favorites[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
minVerticalPadding: 0,
|
||||||
|
leading: SongArtwork(
|
||||||
|
filePath: song.artworkPath,
|
||||||
|
size: 44,
|
||||||
|
fallbackIcon: Icons.music_note,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
song.displayTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
song.displaySubtitle,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Color(0xFFE05C5C),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
await SongDatabase().removeFavorite(song.path);
|
||||||
|
await _loadFavoritesFromDb();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onTap: () => _playFavorites(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaylistsTab() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_playlistSummaries.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 48, color: Colors.grey[600]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'还没有歌单\n在文件列表长按歌曲即可加入',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
20, 0, 20, UIConstants.miniPlayerBottomSpace),
|
||||||
|
itemCount: _playlistSummaries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlistSummaries[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E2A28),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.queue_music,
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${playlist.songCount} 首',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PlaylistDetailPage(
|
||||||
|
playlistId: playlist.id,
|
||||||
|
playlistName: playlist.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _loadPlaylistSummaries();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表
|
||||||
|
Future<void> _playFavorites(int index) async {
|
||||||
|
if (_favorites.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
|
if (headers.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('请先登录 WebDAV'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final songs = _favorites
|
||||||
|
.map((item) => Song(
|
||||||
|
id: item.path,
|
||||||
|
title: item.displayTitle,
|
||||||
|
artist: item.displaySubtitle,
|
||||||
|
artworkPath: item.artworkPath,
|
||||||
|
url: WebDAVService.instance.getFileUrl(item.path),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始');
|
||||||
|
await context.read<AudioService>().setQueue(songs, startIndex: index);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('播放失败: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ⭐ 退出/重新登录对话框
|
// ⭐ 退出/重新登录对话框
|
||||||
void _showReauthDialog() {
|
void _showReauthDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -311,13 +675,17 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// ⭐ 修复:用 push 保留 HomePage 在栈底
|
// 用 push 保留 HomePage 在栈底;登录成功后 Setup 会 popUntil 回到这里
|
||||||
Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const WebDAVSetupPage(),
|
builder: (_) => const WebDAVSetupPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (mounted) {
|
||||||
|
await _initWebDAV();
|
||||||
|
safeSetState(() {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -356,14 +724,21 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'清听',
|
'清听',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.menu, color: Colors.white54),
|
icon: const Icon(Icons.menu, color: Colors.white54),
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const SettingsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -372,12 +747,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: _mediaLibraryHeight > 0
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
? MagneticScrollPhysics(
|
|
||||||
snapPoint: _mediaLibraryHeight,
|
|
||||||
magneticZoneStart: 0.20,
|
|
||||||
)
|
|
||||||
: const ClampingScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
@@ -391,7 +761,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'媒体库',
|
'媒体库',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
),
|
),
|
||||||
@@ -406,6 +776,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
builder: (_) => const WebDAVFileListPage(),
|
builder: (_) => const WebDAVFileListPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _loadFavoritesFromDb();
|
||||||
_resetSnapState();
|
_resetSnapState();
|
||||||
safeSetState(() {});
|
safeSetState(() {});
|
||||||
} else {
|
} else {
|
||||||
@@ -498,7 +869,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -518,7 +889,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'本地音乐',
|
'本地音乐',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -546,35 +917,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'最近播放',
|
'最近播放',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: _ClickableTile(
|
|
||||||
onTap: () {},
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.playlist_play,
|
|
||||||
size: 24,
|
|
||||||
color: const Color(0xFFB8D4D0)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'歌单列表',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -586,7 +929,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -599,128 +942,43 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
color: const Color(0xFF0E1211),
|
color: const Color(0xFF0E1211),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.favorite,
|
Icon(
|
||||||
color: Color(0xFFB8D4D0), size: 20),
|
_homeTab == 0
|
||||||
SizedBox(width: 8),
|
? Icons.favorite
|
||||||
|
: Icons.queue_music,
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'我的收藏',
|
_homeTab == 0 ? '我的收藏' : '歌单列表',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildHomeTabIndicator(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverFillRemaining(
|
||||||
padding: const EdgeInsets.only(
|
hasScrollBody: true,
|
||||||
left: 20,
|
child: PageView(
|
||||||
right: 20,
|
controller: _homePageController,
|
||||||
bottom: UIConstants.miniPlayerBottomSpace,
|
onPageChanged: (index) =>
|
||||||
),
|
safeSetState(() => _homeTab = index),
|
||||||
sliver: _isLoading
|
|
||||||
? const SliverFillRemaining(
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Color(0xFFB8D4D0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: _favorites.isEmpty
|
|
||||||
? SliverFillRemaining(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: 1.0 - _listReveal * 0.3,
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
_buildFavoritesTab(),
|
||||||
Icons.favorite_border,
|
_buildPlaylistsTab(),
|
||||||
size: 48,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
height: 1.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(context, index) {
|
|
||||||
final song = _favorites[index];
|
|
||||||
final itemProgress =
|
|
||||||
(_listReveal * 2 - index / 5)
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return AnimatedOpacity(
|
|
||||||
opacity: itemProgress,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 6),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.music_note,
|
|
||||||
color: Colors.white38,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
song.displayTitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
song.displaySubtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.favorite,
|
|
||||||
color: Color(0xFFB8D4D0),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
safeSetState(() {
|
|
||||||
_favorites.removeAt(index);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onTap: () => _playSong(song),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
childCount: _favorites.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+465
-134
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
// lib/pages/player_page.dart
|
// lib/pages/player_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
@@ -21,6 +25,23 @@ class PlayerPage extends StatefulWidget {
|
|||||||
class _PlayerPageState extends BaseState<PlayerPage> {
|
class _PlayerPageState extends BaseState<PlayerPage> {
|
||||||
double? _dragProgress;
|
double? _dragProgress;
|
||||||
|
|
||||||
|
bool _isFavorite = false;
|
||||||
|
String _album = '';
|
||||||
|
String _folder = '';
|
||||||
|
String _dbTitle = '';
|
||||||
|
String _dbArtist = '';
|
||||||
|
String? _metaSongId;
|
||||||
|
|
||||||
|
/// 0 = 文件信息,1 = 播放主页,2 = 歌词
|
||||||
|
final PageController _pageController = PageController(initialPage: 1);
|
||||||
|
int _pageIndex = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration d) {
|
String _formatDuration(Duration d) {
|
||||||
if (d.inMilliseconds < 0) return '00:00';
|
if (d.inMilliseconds < 0) return '00:00';
|
||||||
final minutes = d.inMinutes;
|
final minutes = d.inMinutes;
|
||||||
@@ -47,14 +68,12 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// ⭐ 页面加载时主动拉取状态(多次重试)
|
|
||||||
_syncStateWithRetry();
|
_syncStateWithRetry();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// ⭐ 依赖变化时(如从后台返回)重新同步
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.read<AudioService>().syncPlayerStateNow();
|
context.read<AudioService>().syncPlayerStateNow();
|
||||||
@@ -64,11 +83,8 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
|
|
||||||
void _syncStateWithRetry() {
|
void _syncStateWithRetry() {
|
||||||
final service = context.read<AudioService>();
|
final service = context.read<AudioService>();
|
||||||
|
|
||||||
// 立即同步
|
|
||||||
service.syncPlayerStateNow();
|
service.syncPlayerStateNow();
|
||||||
|
|
||||||
// 延迟重试
|
|
||||||
const delays = [200, 500, 800, 1200];
|
const delays = [200, 500, 800, 1200];
|
||||||
for (final delay in delays) {
|
for (final delay in delays) {
|
||||||
Future.delayed(Duration(milliseconds: delay), () {
|
Future.delayed(Duration(milliseconds: delay), () {
|
||||||
@@ -79,6 +95,64 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 元数据(专辑、收藏状态)只在切歌时查一次
|
||||||
|
Future<void> _loadMeta(Song song) async {
|
||||||
|
if (_metaSongId == song.id) return;
|
||||||
|
_metaSongId = song.id;
|
||||||
|
|
||||||
|
final db = SongDatabase();
|
||||||
|
String album = '';
|
||||||
|
String dbTitle = '';
|
||||||
|
String dbArtist = '';
|
||||||
|
bool favorite = false;
|
||||||
|
try {
|
||||||
|
final row = await db.getSongByPath(song.id);
|
||||||
|
album = (row?['album'] as String?)?.trim() ?? '';
|
||||||
|
dbTitle = (row?['title'] as String?)?.trim() ?? '';
|
||||||
|
dbArtist = (row?['artist'] as String?)?.trim() ?? '';
|
||||||
|
favorite = await db.isFavorite(song.id);
|
||||||
|
} catch (_) {
|
||||||
|
// 元数据只是装饰,失败忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上级目录名,作为没有专辑信息时的补充
|
||||||
|
String folder = '';
|
||||||
|
final segments =
|
||||||
|
song.id.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
try {
|
||||||
|
folder = Uri.decodeComponent(segments[segments.length - 2]);
|
||||||
|
} catch (_) {
|
||||||
|
folder = segments[segments.length - 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_album = album;
|
||||||
|
_folder = folder;
|
||||||
|
_dbTitle = dbTitle;
|
||||||
|
_dbArtist = dbArtist;
|
||||||
|
_isFavorite = favorite;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite(Song song) async {
|
||||||
|
try {
|
||||||
|
await SongDatabase().toggleFavorite(song.id);
|
||||||
|
FavoriteEvents().publish(song.id);
|
||||||
|
final favorite = await SongDatabase().isFavorite(song.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isFavorite = favorite);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('收藏失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final service = context.watch<AudioService>();
|
final service = context.watch<AudioService>();
|
||||||
@@ -110,6 +184,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_loadMeta(song);
|
||||||
final isPlaying = service.isPlaying;
|
final isPlaying = service.isPlaying;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -118,25 +193,17 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
|
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70),
|
||||||
onPressed: _closePage,
|
onPressed: _closePage,
|
||||||
),
|
),
|
||||||
title: const Text(
|
|
||||||
'正在播放',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
|
title: _buildPageIndicator(_isCompact),
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'clear_cache') {
|
if (value == 'clear_cache') {
|
||||||
final service = context.read<AudioService>();
|
await context.read<AudioService>().clearCurrentSongCache();
|
||||||
await service.clearCurrentSongCache();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -145,9 +212,33 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (value == 'mode') {
|
||||||
|
context.read<AudioService>().togglePlayMode();
|
||||||
|
} else if (value == 'playlist') {
|
||||||
|
_openPlaylist();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mode',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(service.playModeIcon, color: const Color(0xFFB8D4D0)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('切换播放模式'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'playlist',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, color: Color(0xFFB8D4D0)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('播放队列'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'clear_cache',
|
value: 'clear_cache',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -162,71 +253,314 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
top: false,
|
||||||
child: Column(
|
child: PageView(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) => setState(() => _pageIndex = index),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_buildInfoPage(song),
|
||||||
width: 240,
|
_buildMainPage(service, song, isPlaying, compact: _isCompact),
|
||||||
height: 240,
|
if (!_isCompact) _buildLyricsPage(song),
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: const Color(0xFF2A3332),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF7C9A9E).withOpacity(0.15),
|
|
||||||
blurRadius: 60,
|
|
||||||
spreadRadius: 10,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
image: song.artwork != null
|
),
|
||||||
? DecorationImage(
|
),
|
||||||
image: MemoryImage(song.artwork!),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
|
||||||
|
bool get _isCompact {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
if (size.width <= 0) return false;
|
||||||
|
return size.height / size.width < 2.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶部指示器:当前页对应"长条",另外的页是小点。
|
||||||
|
/// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
|
||||||
|
Widget _buildPageIndicator(bool compact) {
|
||||||
|
Widget segment(int index) {
|
||||||
|
final active = _pageIndex == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _pageController.animateToPage(
|
||||||
|
index,
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: active ? 30 : 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? Colors.white : Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final indices = compact ? const [0, 1] : const [0, 1, 2];
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < indices.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 6),
|
||||||
|
segment(indices[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainPage(AudioService service, Song song, bool isPlaying,
|
||||||
|
{required bool compact}) {
|
||||||
|
final screen = MediaQuery.of(context).size;
|
||||||
|
// 小窗:封面改为固定方形(约 30% 可用高度),给控件留出空间
|
||||||
|
final coverSide = compact
|
||||||
|
? (screen.height * 0.38).clamp(160.0, 380.0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final artworkPath = song.artworkPath;
|
||||||
|
final hasArtworkFile = artworkPath != null &&
|
||||||
|
artworkPath.isNotEmpty &&
|
||||||
|
File(artworkPath).existsSync();
|
||||||
|
|
||||||
|
final cover = ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(compact ? 18 : 24),
|
||||||
|
child: song.artwork != null
|
||||||
|
? Image.memory(
|
||||||
|
song.artwork!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
)
|
)
|
||||||
: null,
|
: hasArtworkFile
|
||||||
|
? Image.file(
|
||||||
|
File(artworkPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: const Color(0xFF2A3332),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: compact ? 48 : 72,
|
||||||
|
color: Colors.white24,
|
||||||
),
|
),
|
||||||
child: song.artwork == null
|
|
||||||
? const Icon(Icons.music_note,
|
|
||||||
size: 64, color: Colors.white24)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
);
|
||||||
Column(
|
|
||||||
|
final padded = Padding(
|
||||||
|
// 顶部留白:与 AppBar/指示器拉开距离,避免封面贴着顶部
|
||||||
|
padding: EdgeInsets.fromLTRB(24, compact ? 8 : 24, 24, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ---- 封面 ----
|
||||||
|
if (coverSide != null)
|
||||||
|
Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: coverSide,
|
||||||
|
height: coverSide,
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
SizedBox(height: compact ? 12 : 28),
|
||||||
|
|
||||||
|
// ---- 标题 / 艺术家(收藏心在标题右侧)----
|
||||||
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
song.title,
|
song.title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: compact ? 20 : 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
height: 1.25,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
maxLines: 1,
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _toggleFavorite(song),
|
||||||
|
tooltip: '收藏',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints:
|
||||||
|
const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
iconSize: 24,
|
||||||
|
icon: Icon(
|
||||||
|
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _isFavorite
|
||||||
|
? const Color(0xFFE05C5C)
|
||||||
|
: Colors.white54,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
song.artist,
|
song.artist,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: compact ? 14 : 16,
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[300],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---- 信息区(专辑 / 目录)----
|
||||||
|
if (_album.isNotEmpty || _folder.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (_album.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
_album,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_folder.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'($_folder)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
height: 1.5,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
const Spacer(),
|
||||||
|
|
||||||
|
// ---- 进度 ----
|
||||||
_buildProgressSection(service),
|
_buildProgressSection(service),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: compact ? 10 : 18),
|
||||||
_buildControlButtons(service, isPlaying),
|
|
||||||
const SizedBox(height: 16),
|
// ---- 控制 ----
|
||||||
|
_buildControlButtons(service, song, isPlaying),
|
||||||
|
// 底部多留 50px:控制行整体上移
|
||||||
|
SizedBox(height: compact ? 20 : 45),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!compact) return padded;
|
||||||
|
|
||||||
|
// 小窗/横屏:内容超高时可滚动;内容不足时撑满,避免底部大片空白
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(child: padded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 左页:文件信息
|
||||||
|
Widget _buildInfoPage(Song song) {
|
||||||
|
final rawName = song.id.split('/').last;
|
||||||
|
String fileName = rawName;
|
||||||
|
try {
|
||||||
|
fileName = Uri.decodeComponent(rawName);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final dot = fileName.lastIndexOf('.');
|
||||||
|
final format =
|
||||||
|
dot > 0 ? fileName.substring(dot + 1).toUpperCase() : '未知';
|
||||||
|
|
||||||
|
String decodedPath = song.id;
|
||||||
|
try {
|
||||||
|
decodedPath = Uri.decodeComponent(song.id);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final effectiveArtist = _dbArtist.isNotEmpty
|
||||||
|
? _dbArtist
|
||||||
|
: (song.artist.isEmpty ? '未知' : song.artist);
|
||||||
|
final effectiveTitle = _dbTitle.isNotEmpty ? _dbTitle : song.title;
|
||||||
|
|
||||||
|
final rows = <MapEntry<String, String>>[
|
||||||
|
MapEntry('标题', effectiveTitle),
|
||||||
|
MapEntry('歌手', effectiveArtist),
|
||||||
|
MapEntry('专辑', _album.isEmpty ? '未知' : _album),
|
||||||
|
MapEntry('文件夹', _folder.isEmpty ? '根目录' : _folder),
|
||||||
|
MapEntry('文件名', fileName),
|
||||||
|
MapEntry('格式', format),
|
||||||
|
MapEntry('路径', decodedPath),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'文件信息',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
for (final row in rows) ...[
|
||||||
|
Text(
|
||||||
|
row.key,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
row.value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 右页:歌词(占位)
|
||||||
|
Widget _buildLyricsPage(Song song) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lyrics_outlined, size: 44, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'暂无歌词',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
song.title,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,7 +572,6 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: service.durationNotifier,
|
valueListenable: service.durationNotifier,
|
||||||
builder: (context, duration, _) {
|
builder: (context, duration, _) {
|
||||||
// ⭐ 如果 duration 为 0,显示加载指示器
|
|
||||||
if (duration.inMilliseconds == 0) {
|
if (duration.inMilliseconds == 0) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 8),
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
@@ -249,57 +582,77 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final progress = duration.inMilliseconds > 0
|
final progress = position.inMilliseconds / duration.inMilliseconds;
|
||||||
? position.inMilliseconds / duration.inMilliseconds
|
final bufferProgress = (service
|
||||||
: 0.0;
|
.bufferedNotifier.value.inMilliseconds /
|
||||||
|
|
||||||
final bufferProgress = duration.inMilliseconds > 0
|
|
||||||
? (service.bufferedNotifier.value.inMilliseconds /
|
|
||||||
duration.inMilliseconds)
|
duration.inMilliseconds)
|
||||||
.clamp(0.0, 1.0)
|
.clamp(0.0, 1.0);
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
final displayProgress = _dragProgress ?? progress;
|
final displayProgress = _dragProgress ?? progress;
|
||||||
|
|
||||||
return Column(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Text(
|
||||||
height: 20,
|
_formatDuration(
|
||||||
|
_dragProgress != null
|
||||||
|
? Duration(
|
||||||
|
milliseconds:
|
||||||
|
(_dragProgress! * duration.inMilliseconds)
|
||||||
|
.round(),
|
||||||
|
)
|
||||||
|
: position,
|
||||||
|
),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
final bufferWidth =
|
|
||||||
width * bufferProgress.clamp(0.0, 1.0);
|
|
||||||
final progressWidth =
|
|
||||||
width * displayProgress.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// 背景轨道
|
||||||
width: width,
|
Positioned(
|
||||||
height: 4,
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 10.5,
|
||||||
|
child: Container(
|
||||||
|
height: 3,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
),
|
||||||
width: bufferWidth,
|
// 已缓冲
|
||||||
height: 4,
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 10.5,
|
||||||
|
child: Container(
|
||||||
|
width: width * bufferProgress.clamp(0.0, 1.0),
|
||||||
|
height: 3,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF4A7A7A),
|
color: const Color(0xFF4A7A7A),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
),
|
||||||
width: progressWidth,
|
// 已播放
|
||||||
height: 4,
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 10.5,
|
||||||
|
child: Container(
|
||||||
|
width:
|
||||||
|
width * displayProgress.clamp(0.0, 1.0),
|
||||||
|
height: 3,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFB8D4D0),
|
color: const Color(0xFFB8D4D0),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: SliderTheme(
|
child: SliderTheme(
|
||||||
data: SliderTheme.of(context).copyWith(
|
data: SliderTheme.of(context).copyWith(
|
||||||
@@ -317,9 +670,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() => _dragProgress = value);
|
||||||
_dragProgress = value;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) {
|
onChangeEnd: (value) {
|
||||||
final newPosition = Duration(
|
final newPosition = Duration(
|
||||||
@@ -328,9 +679,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
.round(),
|
.round(),
|
||||||
);
|
);
|
||||||
service.seekTo(newPosition);
|
service.seekTo(newPosition);
|
||||||
setState(() {
|
setState(() => _dragProgress = null);
|
||||||
_dragProgress = null;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -340,24 +689,11 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(duration),
|
_formatDuration(duration),
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -367,61 +703,56 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildControlButtons(AudioService service, bool isPlaying) {
|
Widget _buildControlButtons(
|
||||||
|
AudioService service, Song song, bool isPlaying) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
|
||||||
onPressed: service.togglePlayMode,
|
|
||||||
icon: Icon(
|
|
||||||
service.playModeIcon,
|
|
||||||
color: const Color(0xFFB8D4D0),
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: service.hasQueue ? service.previous : null,
|
onPressed: service.hasQueue ? service.previous : null,
|
||||||
icon: const Icon(Icons.skip_previous, size: 28),
|
icon: const Icon(Icons.skip_previous_rounded, size: 34),
|
||||||
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
|
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 68,
|
||||||
height: 56,
|
height: 68,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: const Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
size: 28,
|
size: 36,
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
onPressed: service.togglePlay,
|
onPressed: service.togglePlay,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: service.hasQueue ? service.next : null,
|
onPressed: service.hasQueue ? service.next : null,
|
||||||
icon: const Icon(Icons.skip_next, size: 28),
|
icon: const Icon(Icons.skip_next_rounded, size: 34),
|
||||||
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
|
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
|
||||||
padding: const EdgeInsets.all(12),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: service.togglePlayMode,
|
||||||
|
icon: Icon(
|
||||||
|
service.playModeIcon,
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
tooltip: '播放模式',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _openPlaylist,
|
onPressed: _openPlaylist,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.playlist_play_outlined,
|
Icons.queue_music_rounded,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
size: 26,
|
size: 26,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
tooltip: '播放队列',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
// lib/pages/playlist_detail_page.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
|
||||||
|
/// 歌单详情:展示歌单内歌曲,支持播放全部 / 单曲播放 / 移除
|
||||||
|
class PlaylistDetailPage extends StatefulWidget {
|
||||||
|
final String playlistId;
|
||||||
|
final String playlistName;
|
||||||
|
|
||||||
|
const PlaylistDetailPage({
|
||||||
|
super.key,
|
||||||
|
required this.playlistId,
|
||||||
|
required this.playlistName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistDetailPage> createState() => _PlaylistDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SongRow {
|
||||||
|
final String path;
|
||||||
|
final String title;
|
||||||
|
final String artist;
|
||||||
|
final String? artworkPath;
|
||||||
|
|
||||||
|
const _SongRow({
|
||||||
|
required this.path,
|
||||||
|
required this.title,
|
||||||
|
required this.artist,
|
||||||
|
this.artworkPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
|
||||||
|
final PlaylistRepository _repo = PlaylistRepository();
|
||||||
|
final SongDatabase _db = SongDatabase();
|
||||||
|
|
||||||
|
List<_SongRow> _songs = [];
|
||||||
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_metaSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 歌单里的歌补全元数据后,就地更新对应行
|
||||||
|
void _onMetadataUpdate(MetadataUpdate update) {
|
||||||
|
final index = _songs.indexWhere((item) => item.path == update.path);
|
||||||
|
if (index < 0 || !mounted) return;
|
||||||
|
final row = update.row;
|
||||||
|
final title = (row['title'] as String?)?.trim() ?? '';
|
||||||
|
final artist = (row['artist'] as String?)?.trim() ?? '';
|
||||||
|
final artwork = (row['artwork_path'] as String?)?.trim() ?? '';
|
||||||
|
final old = _songs[index];
|
||||||
|
setState(() {
|
||||||
|
_songs[index] = _SongRow(
|
||||||
|
path: old.path,
|
||||||
|
title: title.isNotEmpty ? title : old.title,
|
||||||
|
artist: artist.isNotEmpty ? artist : old.artist,
|
||||||
|
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final ids = await _repo.getPlaylistSongIds(widget.playlistId);
|
||||||
|
final meta = await _db.getSongsByPaths(ids);
|
||||||
|
final rows = <_SongRow>[];
|
||||||
|
for (final id in ids) {
|
||||||
|
final row = meta[id];
|
||||||
|
final title = (row?['title'] as String?)?.trim() ?? '';
|
||||||
|
final artist = (row?['artist'] as String?)?.trim() ?? '';
|
||||||
|
String fallbackName = id.split('/').last;
|
||||||
|
try {
|
||||||
|
fallbackName = Uri.decodeComponent(fallbackName);
|
||||||
|
} catch (_) {}
|
||||||
|
rows.add(_SongRow(
|
||||||
|
path: id,
|
||||||
|
title: title.isNotEmpty
|
||||||
|
? title
|
||||||
|
: fallbackName.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
|
artist: artist.isNotEmpty ? artist : '未知艺术家',
|
||||||
|
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty ==
|
||||||
|
true
|
||||||
|
? (row!['artwork_path'] as String).trim()
|
||||||
|
: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_songs = rows;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('读取歌单失败: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play(int index) async {
|
||||||
|
await context
|
||||||
|
.read<AudioService>()
|
||||||
|
.playPlaylist(widget.playlistId, startIndex: index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _remove(_SongRow song) async {
|
||||||
|
await _repo.removeSong(widget.playlistId, song.path);
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E1211),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(widget.playlistName),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, color: Color(0xFFB8D4D0)),
|
||||||
|
tooltip: '播放全部',
|
||||||
|
onPressed: _songs.isEmpty ? null : () => _play(0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _songs.isEmpty
|
||||||
|
? _buildEmpty()
|
||||||
|
: ListView.builder(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
itemCount: _songs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final song = _songs[index];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: SongArtwork(
|
||||||
|
filePath: song.artworkPath,
|
||||||
|
size: 44,
|
||||||
|
fallbackIcon: Icons.music_note,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
song.title,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.white, fontSize: 16),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
song.artist,
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close,
|
||||||
|
color: Colors.white24, size: 18),
|
||||||
|
tooltip: '从歌单移除',
|
||||||
|
onPressed: () => _remove(song),
|
||||||
|
),
|
||||||
|
onTap: () => _play(index),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_off_outlined, size: 44, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'歌单还是空的',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'在文件列表长按歌曲即可加入',
|
||||||
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
|
||||||
class PlaylistPage extends StatefulWidget {
|
class PlaylistPage extends StatefulWidget {
|
||||||
const PlaylistPage({super.key});
|
const PlaylistPage({super.key});
|
||||||
@@ -17,6 +19,9 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
|||||||
int _scrollGeneration = 0;
|
int _scrollGeneration = 0;
|
||||||
bool _hasScrolledToCurrent = false;
|
bool _hasScrolledToCurrent = false;
|
||||||
|
|
||||||
|
/// path -> 封面文件路径(来自 songs 表)
|
||||||
|
Map<String, String> _artworkByPath = {};
|
||||||
|
|
||||||
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
|
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
|
||||||
// 实际约 72-76px,取 74px 作为稳定值
|
// 实际约 72-76px,取 74px 作为稳定值
|
||||||
static const double _itemExtent = 74.0;
|
static const double _itemExtent = 74.0;
|
||||||
@@ -28,13 +33,46 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadArtworkPaths();
|
||||||
|
AudioService().addListener(_onAudioChanged);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToCurrentSong();
|
_scrollToCurrentSong();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _lastArtworkSongId;
|
||||||
|
|
||||||
|
/// 切歌后可能有新歌补全了封面,重新拉一次路径表
|
||||||
|
void _onAudioChanged() {
|
||||||
|
final song = AudioService().currentSong;
|
||||||
|
if (song == null || song.id == _lastArtworkSongId) return;
|
||||||
|
_lastArtworkSongId = song.id;
|
||||||
|
_loadArtworkPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量取封面文件路径,供列表显示
|
||||||
|
Future<void> _loadArtworkPaths() async {
|
||||||
|
try {
|
||||||
|
final queue = AudioService().queue;
|
||||||
|
if (queue.isEmpty) return;
|
||||||
|
final rows =
|
||||||
|
await SongDatabase().getSongsByPaths(queue.map((s) => s.id).toList());
|
||||||
|
final map = <String, String>{};
|
||||||
|
rows.forEach((path, row) {
|
||||||
|
final artwork = row['artwork_path'] as String?;
|
||||||
|
if (artwork != null && artwork.isNotEmpty) map[path] = artwork;
|
||||||
|
});
|
||||||
|
if (mounted && map.isNotEmpty) {
|
||||||
|
setState(() => _artworkByPath = map);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 封面只是装饰,失败不影响列表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
AudioService().removeListener(_onAudioChanged);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -271,11 +309,15 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
|||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
leading: Icon(
|
leading: SongArtwork(
|
||||||
|
bytes: song.artwork,
|
||||||
|
filePath: _artworkByPath[song.id],
|
||||||
|
size: 48,
|
||||||
|
fallbackIcon:
|
||||||
isCurrent ? Icons.play_arrow : Icons.music_note,
|
isCurrent ? Icons.play_arrow : Icons.music_note,
|
||||||
color:
|
fallbackColor: isCurrent
|
||||||
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600],
|
? const Color(0xFFB8D4D0)
|
||||||
size: 24,
|
: Colors.grey[600],
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
song.title,
|
song.title,
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// lib/pages/playlists_page.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
import 'playlist_detail_page.dart';
|
||||||
|
|
||||||
|
/// 歌单集合页:展示所有歌单,支持新建 / 重命名 / 删除
|
||||||
|
class PlaylistsPage extends StatefulWidget {
|
||||||
|
const PlaylistsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistsPage> createState() => _PlaylistsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistsPageState extends State<PlaylistsPage> {
|
||||||
|
final PlaylistRepository _repo = PlaylistRepository();
|
||||||
|
List<PlaylistSummary> _playlists = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final list = await _repo.getSummaries();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_playlists = list;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('读取歌单失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _create() async {
|
||||||
|
final name = await _promptName('新建歌单');
|
||||||
|
if (name == null || name.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await _repo.createPlaylist(name);
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('新建失败: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _rename(PlaylistSummary playlist) async {
|
||||||
|
final name = await _promptName('重命名歌单', initial: playlist.name);
|
||||||
|
if (name == null || name.isEmpty) return;
|
||||||
|
await _repo.updatePlaylistName(playlist.id, name);
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(PlaylistSummary playlist) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text('删除歌单', style: TextStyle(color: Colors.white)),
|
||||||
|
content: Text(
|
||||||
|
'删除「${playlist.name}」?歌单内的歌曲不会从网盘删除。',
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('删除', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
await _repo.deletePlaylist(playlist.id);
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _promptName(String title, {String initial = ''}) async {
|
||||||
|
final controller = TextEditingController(text: initial);
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: Text(title, style: const TextStyle(color: Colors.white)),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(hintText: '歌单名称'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E1211),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('歌单'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
|
||||||
|
tooltip: '新建歌单',
|
||||||
|
onPressed: _create,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _playlists.isEmpty
|
||||||
|
? _buildEmpty()
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
itemCount: _playlists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlists[index];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: SongArtwork(
|
||||||
|
filePath: playlist.coverPath,
|
||||||
|
size: 52,
|
||||||
|
fallbackIcon: Icons.queue_music,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 16),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${playlist.songCount} 首',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
color: Colors.white38, size: 20),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'rename') _rename(playlist);
|
||||||
|
if (value == 'delete') _delete(playlist);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'rename',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.edit_outlined,
|
||||||
|
color: Color(0xFFB8D4D0), size: 18),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('重命名'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_outline,
|
||||||
|
color: Colors.redAccent, size: 18),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('删除'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PlaylistDetailPage(
|
||||||
|
playlistId: playlist.id,
|
||||||
|
playlistName: playlist.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 48, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'还没有歌单',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'点右上角 + 新建一个',
|
||||||
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+352
-44
@@ -1,59 +1,367 @@
|
|||||||
import 'package:flutter/material.dart';
|
// lib/pages/settings_page.dart
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
import 'package:android_intent_plus/android_intent.dart';
|
||||||
|
import 'package:android_intent_plus/flag.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/webdav_service.dart';
|
||||||
|
import '../utils/file_provider_utils.dart';
|
||||||
|
|
||||||
|
/// 与 pubspec.yaml 的 version 保持同步(发版时一起改)
|
||||||
|
const String kAppVersion = '1.1.0-dev.1';
|
||||||
|
const int kAppBuild = 2;
|
||||||
|
|
||||||
|
class SettingsPage extends StatefulWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<SettingsPage> createState() => _SettingsPageState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(title: const Text('设置')),
|
|
||||||
body: ListView(
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
children: [
|
bool _loading = true;
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildTile(
|
int _songRows = 0;
|
||||||
icon: Icons.folder_open_outlined,
|
int _metadataRows = 0;
|
||||||
title: '本地文件 (SAF)',
|
int _favoriteRows = 0;
|
||||||
subtitle: '通过系统文件选择器导入',
|
int _artworkCount = 0;
|
||||||
onTap: () {
|
int _artworkBytes = 0;
|
||||||
// 🟢 这里粘贴你旧项目的 SAF 逻辑
|
int _tempCount = 0;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
int _tempBytes = 0;
|
||||||
const SnackBar(content: Text('待接入 SAF 逻辑')),
|
|
||||||
);
|
@override
|
||||||
},
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStats() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final db = await SongDatabase().database;
|
||||||
|
final songRows =
|
||||||
|
(await db.rawQuery('SELECT COUNT(*) AS c FROM songs')).first['c'];
|
||||||
|
final metaRows = (await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) AS c FROM metadata_cache'))
|
||||||
|
.first['c'];
|
||||||
|
final favRows =
|
||||||
|
(await db.rawQuery('SELECT COUNT(*) AS c FROM favorites')).first['c'];
|
||||||
|
|
||||||
|
var artworkCount = 0, artworkBytes = 0;
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final artDir = Directory('${docs.path}/artworks');
|
||||||
|
if (await artDir.exists()) {
|
||||||
|
await for (final entity in artDir.list()) {
|
||||||
|
if (entity is File) {
|
||||||
|
artworkCount++;
|
||||||
|
artworkBytes += await entity.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempCount = 0, tempBytes = 0;
|
||||||
|
final tmp = await getTemporaryDirectory();
|
||||||
|
if (await tmp.exists()) {
|
||||||
|
await for (final entity in tmp.list()) {
|
||||||
|
if (entity is File &&
|
||||||
|
entity.path.contains('/metadata_') &&
|
||||||
|
entity.path.endsWith('.part')) {
|
||||||
|
tempCount++;
|
||||||
|
tempBytes += await entity.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_songRows = songRows as int? ?? 0;
|
||||||
|
_metadataRows = metaRows as int? ?? 0;
|
||||||
|
_favoriteRows = favRows as int? ?? 0;
|
||||||
|
_artworkCount = artworkCount;
|
||||||
|
_artworkBytes = artworkBytes;
|
||||||
|
_tempCount = tempCount;
|
||||||
|
_tempBytes = tempBytes;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Settings] 统计失败: $e');
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearMetadataCache() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text('清理元数据缓存', style: TextStyle(color: Colors.white)),
|
||||||
|
content: const Text(
|
||||||
|
'会删除歌曲标签缓存和封面文件,歌单与收藏不受影响。\n下次播放时重新抓取。',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
_buildTile(
|
actions: [
|
||||||
icon: Icons.cloud_outlined,
|
TextButton(
|
||||||
title: 'WebDAV 云盘',
|
onPressed: () => Navigator.pop(context, false),
|
||||||
subtitle: '连接你的 NAS 或网盘',
|
child: const Text('取消'),
|
||||||
onTap: () {
|
|
||||||
// 🟢 这里调用你现成的 WebDAV 通道
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
_buildTile(
|
TextButton(
|
||||||
icon: Icons.equalizer_outlined,
|
onPressed: () => Navigator.pop(context, true),
|
||||||
title: '均衡器',
|
child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
|
||||||
subtitle: '简单低音增强 (后续)',
|
),
|
||||||
onTap: () {},
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final db = await SongDatabase().database;
|
||||||
|
await db.delete('songs');
|
||||||
|
await db.delete('metadata_cache');
|
||||||
|
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final artDir = Directory('${docs.path}/artworks');
|
||||||
|
if (await artDir.exists()) {
|
||||||
|
await artDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tmp = await getTemporaryDirectory();
|
||||||
|
if (await tmp.exists()) {
|
||||||
|
await for (final entity in tmp.list()) {
|
||||||
|
if (entity is File &&
|
||||||
|
entity.path.contains('/metadata_') &&
|
||||||
|
entity.path.endsWith('.part')) {
|
||||||
|
await entity.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('元数据缓存已清理')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _loadStats();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('清理失败: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportDiagnostics() async {
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final file =
|
||||||
|
File('${docs.path}/diagnostics-${now.millisecondsSinceEpoch}.txt');
|
||||||
|
|
||||||
|
final song = AudioService().currentSong;
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('清听 诊断信息')
|
||||||
|
..writeln('时间: ${now.toIso8601String()}')
|
||||||
|
..writeln('版本: $kAppVersion ($kAppBuild)')
|
||||||
|
..writeln('平台: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
|
||||||
|
..writeln()
|
||||||
|
..writeln('[连接]')
|
||||||
|
..writeln('baseUrl: ${WebDAVService.instance.baseUrl ?? '(未配置)'}')
|
||||||
|
..writeln('username: ${WebDAVService.instance.username ?? '(未配置)'}')
|
||||||
|
..writeln('connected: ${WebDAVService.instance.isConnected}')
|
||||||
|
..writeln()
|
||||||
|
..writeln('[数据]')
|
||||||
|
..writeln('songs: $_songRows')
|
||||||
|
..writeln('metadata_cache: $_metadataRows')
|
||||||
|
..writeln('favorites: $_favoriteRows')
|
||||||
|
..writeln('artwork 文件: $_artworkCount 个 / ${_formatBytes(_artworkBytes)}')
|
||||||
|
..writeln('临时探测文件: $_tempCount 个 / ${_formatBytes(_tempBytes)}')
|
||||||
|
..writeln()
|
||||||
|
..writeln('[当前播放]')
|
||||||
|
..writeln('song: ${song?.title ?? '(无)'} - ${song?.artist ?? ''}')
|
||||||
|
..writeln('path: ${song?.id ?? ''}')
|
||||||
|
..writeln('position: ${AudioService().position}')
|
||||||
|
..writeln('duration: ${AudioService().duration}')
|
||||||
|
..writeln('lastError: ${AudioService().lastPlaybackError ?? '(无)'}');
|
||||||
|
|
||||||
|
await file.writeAsString(buffer.toString());
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final uri = await FileProviderUtils.getContentUri(file);
|
||||||
|
if (uri != null) {
|
||||||
|
try {
|
||||||
|
final intent = AndroidIntent(
|
||||||
|
action: 'android.intent.action.SEND',
|
||||||
|
type: 'text/plain',
|
||||||
|
arguments: {
|
||||||
|
'android.intent.extra.STREAM': uri.toString(),
|
||||||
|
'android.intent.extra.SUBJECT': '清听诊断信息',
|
||||||
|
},
|
||||||
|
flags: const [Flag.FLAG_GRANT_READ_URI_PERMISSION],
|
||||||
|
);
|
||||||
|
await intent.launch();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Settings] 分享失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text('诊断信息已导出', style: TextStyle(color: Colors.white)),
|
||||||
|
content: SelectableText(
|
||||||
|
file.path,
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('好'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('导出失败: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTile(
|
Future<void> _testConnection() async {
|
||||||
{required IconData icon,
|
try {
|
||||||
required String title,
|
await WebDAVService.instance.listDirectory(path: '/');
|
||||||
required String subtitle,
|
if (mounted) {
|
||||||
required VoidCallback onTap}) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
return ListTile(
|
const SnackBar(content: Text('连接正常')),
|
||||||
leading: Icon(icon, color: const Color(0xFF7C9A9E)),
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
|
|
||||||
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('连接失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
await WebDAVService.instance.clearCredentials();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('已退出登录,下次进入会要求重新配置')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E1211),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('设置'),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
children: [
|
||||||
|
_sectionTitle('缓存'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cleaning_services_outlined,
|
||||||
|
color: Color(0xFF7C9A9E)),
|
||||||
|
title: const Text('元数据缓存'),
|
||||||
|
subtitle: Text(
|
||||||
|
'标签 $_songRows 首 · 封面 $_artworkCount 张 '
|
||||||
|
'(${_formatBytes(_artworkBytes)}) · 临时文件 ${_formatBytes(_tempBytes)}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
|
||||||
|
onTap: _clearMetadataCache,
|
||||||
|
),
|
||||||
|
_sectionTitle('诊断'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.bug_report_outlined,
|
||||||
|
color: Color(0xFF7C9A9E)),
|
||||||
|
title: const Text('导出诊断信息'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'版本、连接状态、缓存统计、当前播放与最近错误',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.ios_share, color: Colors.grey),
|
||||||
|
onTap: _exportDiagnostics,
|
||||||
|
),
|
||||||
|
_sectionTitle('连接'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_outlined,
|
||||||
|
color: Color(0xFF7C9A9E)),
|
||||||
|
title: Text(WebDAVService.instance.baseUrl ?? '(未配置)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'用户 ${WebDAVService.instance.username ?? '-'} · '
|
||||||
|
'${WebDAVService.instance.isConnected ? '已连接' : '未连接'}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.wifi_tethering,
|
||||||
|
color: Color(0xFF7C9A9E)),
|
||||||
|
title: const Text('测试连接'),
|
||||||
|
onTap: _testConnection,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading:
|
||||||
|
const Icon(Icons.logout, color: Colors.redAccent),
|
||||||
|
title: const Text('退出登录'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'清除本机保存的 WebDAV 账号',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: _logout,
|
||||||
|
),
|
||||||
|
_sectionTitle('关于'),
|
||||||
|
ListTile(
|
||||||
|
leading:
|
||||||
|
const Icon(Icons.info_outline, color: Color(0xFF7C9A9E)),
|
||||||
|
title: const Text('清听'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'版本 $kAppVersion ($kAppBuild)\n'
|
||||||
|
'播放内核 media_kit (libmpv, LGPL/GPL) · UI Flutter (BSD)',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionTitle(String text) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 20, 4, 8),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
// lib/pages/webdav_file_list_page.dart
|
// lib/pages/webdav_file_list_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import '../constants/ui_constants.dart';
|
import '../constants/ui_constants.dart';
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
import '../controllers/playback_controller.dart';
|
import '../controllers/playback_controller.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
import '../widgets/add_to_playlist_sheet.dart';
|
||||||
|
import 'webdav_search_page.dart';
|
||||||
|
|
||||||
class WebDAVFileListPage extends StatefulWidget {
|
class WebDAVFileListPage extends StatefulWidget {
|
||||||
final String currentPath;
|
final String currentPath;
|
||||||
@@ -20,6 +28,14 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
String _errorMessage = '';
|
String _errorMessage = '';
|
||||||
String _currentPath = '/';
|
String _currentPath = '/';
|
||||||
|
|
||||||
|
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
|
||||||
|
Map<String, Map<String, dynamic>> _metaByPath = {};
|
||||||
|
String? _lastSyncedSongPath;
|
||||||
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
||||||
|
|
||||||
|
/// 已收藏的歌曲路径
|
||||||
|
Set<String> _favoritePaths = {};
|
||||||
|
|
||||||
// 安全解码
|
// 安全解码
|
||||||
String _safeDecode(String input) {
|
String _safeDecode(String input) {
|
||||||
try {
|
try {
|
||||||
@@ -33,9 +49,70 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentPath = widget.currentPath;
|
_currentPath = widget.currentPath;
|
||||||
|
AudioService().addListener(_onPlaybackChanged);
|
||||||
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
||||||
_loadDirectory();
|
_loadDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_metaSub?.cancel();
|
||||||
|
AudioService().removeListener(_onPlaybackChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据补全后,按 path 更新列表行的标题/艺术家/封面
|
||||||
|
void _onMetadataUpdate(MetadataUpdate update) {
|
||||||
|
if (!_items.any((item) => item.path == update.path)) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
safeSetState(() {
|
||||||
|
_metaByPath = {..._metaByPath, update.path: update.row};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
|
||||||
|
void _onPlaybackChanged() {
|
||||||
|
final path = AudioService().currentSong?.id;
|
||||||
|
if (path == null || path == _lastSyncedSongPath) return;
|
||||||
|
_lastSyncedSongPath = path;
|
||||||
|
_syncMetadataFor(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncMetadataFor(String path) async {
|
||||||
|
if (!_items.any((item) => item.path == path)) return;
|
||||||
|
final row = await SongDatabase().getSongByPath(path);
|
||||||
|
if (row == null || !mounted) return;
|
||||||
|
final title = (row['title'] as String?)?.trim() ?? '';
|
||||||
|
if (title.isEmpty) return;
|
||||||
|
safeSetState(() {
|
||||||
|
_metaByPath = {..._metaByPath, path: row};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏 / 取消收藏:整行点击是播放,右侧红心专门做收藏
|
||||||
|
Future<void> _toggleFavorite(String path) async {
|
||||||
|
final wasFavorite = _favoritePaths.contains(path);
|
||||||
|
try {
|
||||||
|
await SongDatabase().toggleFavorite(path);
|
||||||
|
FavoriteEvents().publish(path);
|
||||||
|
if (!mounted) return;
|
||||||
|
safeSetState(() {
|
||||||
|
final next = {..._favoritePaths};
|
||||||
|
if (wasFavorite) {
|
||||||
|
next.remove(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
_favoritePaths = next;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('收藏失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 加载目录
|
// 加载目录
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -48,11 +125,40 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
try {
|
try {
|
||||||
final items =
|
final items =
|
||||||
await WebDAVService.instance.listDirectory(path: _currentPath);
|
await WebDAVService.instance.listDirectory(path: _currentPath);
|
||||||
|
|
||||||
|
// 一次性把本目录下所有文件的元数据查出来,列表直接用标签显示
|
||||||
|
final paths = items
|
||||||
|
.where((item) => !item.isDirectory)
|
||||||
|
.map((item) => item.path)
|
||||||
|
.toList();
|
||||||
|
var meta = <String, Map<String, dynamic>>{};
|
||||||
|
try {
|
||||||
|
meta = await SongDatabase().getSongsByPaths(paths);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [WebDAVList] 批量读取元数据失败: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
var favorites = <String>{};
|
||||||
|
try {
|
||||||
|
final favRows = await SongDatabase().getFavorites();
|
||||||
|
favorites = favRows
|
||||||
|
.map((row) => row['song_id'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [WebDAVList] 读取收藏失败: $e');
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
safeSetState(() {
|
safeSetState(() {
|
||||||
_items = items;
|
_items = items;
|
||||||
|
_metaByPath = meta;
|
||||||
|
_favoritePaths = favorites;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
// 如果当前正在播的歌就在这个目录里,补一次同步
|
||||||
|
_lastSyncedSongPath = null;
|
||||||
|
_onPlaybackChanged();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -175,9 +281,17 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.playlist_play),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: _playAll,
|
onPressed: () => Navigator.push(
|
||||||
tooltip: '全部播放',
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => WebDAVSearchPage(
|
||||||
|
directoryPath: _currentPath,
|
||||||
|
items: _items,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tooltip: '搜索本文件夹',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
@@ -262,7 +376,8 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
|
|
||||||
if (item.isDirectory) {
|
if (item.isDirectory) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
Icons.folder_outlined,
|
Icons.folder_outlined,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
@@ -295,15 +410,28 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
final sizeStr = item.size != null
|
final sizeStr = item.size != null
|
||||||
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
|
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
|
||||||
: '';
|
: '';
|
||||||
|
// 有标签就用标签,没有才退回文件名(例如新歌尚未抓取元数据)
|
||||||
|
final meta = _metaByPath[item.path];
|
||||||
|
final metaTitle = (meta?['title'] as String?)?.trim() ?? '';
|
||||||
|
final metaArtist = (meta?['artist'] as String?)?.trim() ?? '';
|
||||||
|
final hasMeta = metaTitle.isNotEmpty;
|
||||||
|
final titleText = hasMeta ? metaTitle : displayName;
|
||||||
|
final subtitleText = [
|
||||||
|
if (hasMeta && metaArtist.isNotEmpty && metaArtist != '未知艺术家')
|
||||||
|
metaArtist,
|
||||||
|
if (sizeStr.isNotEmpty) sizeStr,
|
||||||
|
].join(' · ');
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
leading: const Icon(
|
visualDensity: VisualDensity.compact,
|
||||||
Icons.audiotrack,
|
leading: SongArtwork(
|
||||||
color: Color(0xFFB8D4D0),
|
filePath: meta?['artwork_path'] as String?,
|
||||||
size: 28,
|
size: 44,
|
||||||
|
fallbackIcon: Icons.audiotrack,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
displayName,
|
titleText,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
@@ -313,17 +441,26 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
sizeStr,
|
subtitleText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.play_arrow, color: Color(0xFFB8D4D0)),
|
tooltip: '收藏',
|
||||||
onPressed: () => _playSong(item),
|
icon: Icon(
|
||||||
|
_favoritePaths.contains(item.path)
|
||||||
|
? Icons.favorite
|
||||||
|
: Icons.favorite_border,
|
||||||
|
color: _favoritePaths.contains(item.path)
|
||||||
|
? const Color(0xFFE05C5C)
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
onPressed: () => _toggleFavorite(item.path),
|
||||||
),
|
),
|
||||||
onTap: () => _playSong(item),
|
onTap: () => _playSong(item),
|
||||||
|
onLongPress: () => showAddToPlaylistSheet(context, songPath: item.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// lib/pages/webdav_search_page.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../controllers/playback_controller.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/webdav_service.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
|
||||||
|
/// 在当前 WebDAV 文件夹内搜索歌曲
|
||||||
|
class WebDAVSearchPage extends StatefulWidget {
|
||||||
|
final String directoryPath;
|
||||||
|
final List<WebDAVItem> items;
|
||||||
|
|
||||||
|
const WebDAVSearchPage({
|
||||||
|
super.key,
|
||||||
|
required this.directoryPath,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebDAVSearchPage> createState() => _WebDAVSearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebDAVSearchPageState extends State<WebDAVSearchPage> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
|
/// path -> songs 表行
|
||||||
|
Map<String, Map<String, dynamic>> _meta = {};
|
||||||
|
String _query = '';
|
||||||
|
|
||||||
|
List<WebDAVItem> get _files =>
|
||||||
|
widget.items.where((item) => !item.isDirectory).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMeta() async {
|
||||||
|
try {
|
||||||
|
final paths = _files.map((item) => item.path).toList();
|
||||||
|
final meta = await SongDatabase().getSongsByPaths(paths);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _meta = meta);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Search] 读取元数据失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decode(String input) {
|
||||||
|
try {
|
||||||
|
return Uri.decodeComponent(input);
|
||||||
|
} catch (_) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WebDAVItem> get _results {
|
||||||
|
final keyword = _query.trim().toLowerCase();
|
||||||
|
if (keyword.isEmpty) return const [];
|
||||||
|
return _files.where((item) {
|
||||||
|
final name = _decode(item.name).toLowerCase();
|
||||||
|
final meta = _meta[item.path];
|
||||||
|
final title = (meta?['title'] as String? ?? '').toLowerCase();
|
||||||
|
final artist = (meta?['artist'] as String? ?? '').toLowerCase();
|
||||||
|
return name.contains(keyword) ||
|
||||||
|
title.contains(keyword) ||
|
||||||
|
artist.contains(keyword);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play(WebDAVItem item) async {
|
||||||
|
try {
|
||||||
|
await PlaybackController().playFromWebDAV(
|
||||||
|
directoryPath: widget.directoryPath,
|
||||||
|
clickedSongPath: item.path,
|
||||||
|
allItems: widget.items,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('播放失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final results = _results;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E1211),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||||
|
cursorColor: const Color(0xFFB8D4D0),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '搜索本文件夹的歌曲',
|
||||||
|
hintStyle: TextStyle(color: Colors.white38, fontSize: 15),
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
onChanged: (value) => setState(() => _query = value),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_query.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white54),
|
||||||
|
tooltip: '清除',
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
setState(() => _query = '');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _query.trim().isEmpty
|
||||||
|
? _buildHint()
|
||||||
|
: results.isEmpty
|
||||||
|
? _buildEmpty()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
itemCount: results.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = results[index];
|
||||||
|
final meta = _meta[item.path];
|
||||||
|
final metaTitle =
|
||||||
|
(meta?['title'] as String?)?.trim() ?? '';
|
||||||
|
final metaArtist =
|
||||||
|
(meta?['artist'] as String?)?.trim() ?? '';
|
||||||
|
final artwork =
|
||||||
|
(meta?['artwork_path'] as String?)?.trim() ?? '';
|
||||||
|
final name = _decode(item.name);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: SongArtwork(
|
||||||
|
filePath: artwork.isNotEmpty ? artwork : null,
|
||||||
|
size: 44,
|
||||||
|
fallbackIcon: Icons.audiotrack,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
metaTitle.isNotEmpty ? metaTitle : name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 16),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
metaArtist.isNotEmpty
|
||||||
|
? metaArtist
|
||||||
|
: (item.size != null
|
||||||
|
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
|
||||||
|
: ''),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () => _play(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHint() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, size: 48, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'输入歌名、歌手或文件名',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'只在当前文件夹内搜索',
|
||||||
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty() {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'没有找到「${_query.trim()}」',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import 'webdav_file_list_page.dart';
|
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
|
|
||||||
class WebDAVSetupPage extends StatefulWidget {
|
class WebDAVSetupPage extends StatefulWidget {
|
||||||
@@ -72,14 +71,10 @@ class _WebDAVSetupPageState extends BaseState<WebDAVSetupPage> {
|
|||||||
backgroundColor: Color(0xFF4CAF50),
|
backgroundColor: Color(0xFF4CAF50),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// ⭐ 用 pushReplacement 替换当前页面(WebDAVSetupPage)
|
// 登录成功后直接回到主页:
|
||||||
// 此时栈底是 HomePage,替换后栈变为 [HomePage, WebDAVFileListPage]
|
// - 首次配置:用户配完先进主页,再自己点 WebDAV 进入文件列表
|
||||||
Navigator.pushReplacement(
|
// - 重新登录:从主页 push 进来,直接回主页,不会停在文件列表
|
||||||
context,
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const WebDAVFileListPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
safeSetState(() {
|
safeSetState(() {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../services/playlist_events.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
|
||||||
class PlaylistRepository {
|
class PlaylistRepository {
|
||||||
@@ -34,14 +36,36 @@ class PlaylistRepository {
|
|||||||
Future<Playlist> createPlaylist(String name) async {
|
Future<Playlist> createPlaylist(String name) async {
|
||||||
final playlist = Playlist(id: _generateId(), name: name);
|
final playlist = Playlist(id: _generateId(), name: name);
|
||||||
await _db.insertPlaylist(playlist.toJson());
|
await _db.insertPlaylist(playlist.toJson());
|
||||||
|
PlaylistEvents().publish();
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单列表摘要(歌曲数 + 封面),给主页/列表页用
|
||||||
|
Future<List<PlaylistSummary>> getSummaries() async {
|
||||||
|
final rows = await _db.getPlaylistSummaries();
|
||||||
|
return rows.map(PlaylistSummary.fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量加入歌曲(自动跳过歌单里已有的,避免打乱顺序)
|
||||||
|
Future<int> addSongs(String playlistId, List<String> songIds) async {
|
||||||
|
if (songIds.isEmpty) return 0;
|
||||||
|
final existing = (await getPlaylistSongIds(playlistId)).toSet();
|
||||||
|
var added = 0;
|
||||||
|
for (final id in songIds) {
|
||||||
|
if (existing.contains(id)) continue;
|
||||||
|
await _db.addSongToPlaylist(playlistId, id);
|
||||||
|
existing.add(id);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (added > 0) PlaylistEvents().publish();
|
||||||
|
return added;
|
||||||
|
}
|
||||||
Future<void> updatePlaylistName(String id, String newName) async {
|
Future<void> updatePlaylistName(String id, String newName) async {
|
||||||
await _db.updatePlaylist(id, {
|
await _db.updatePlaylist(id, {
|
||||||
'name': newName,
|
'name': newName,
|
||||||
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
});
|
});
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 新增:更新播放模式
|
// ⭐ 新增:更新播放模式
|
||||||
@@ -58,6 +82,7 @@ class PlaylistRepository {
|
|||||||
|
|
||||||
Future<void> deletePlaylist(String id) async {
|
Future<void> deletePlaylist(String id) async {
|
||||||
await _db.deletePlaylist(id);
|
await _db.deletePlaylist(id);
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -75,6 +100,7 @@ class PlaylistRepository {
|
|||||||
|
|
||||||
Future<void> removeSong(String playlistId, String songId) async {
|
Future<void> removeSong(String playlistId, String songId) async {
|
||||||
await _db.removeSongFromPlaylist(playlistId, songId);
|
await _db.removeSongFromPlaylist(playlistId, songId);
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reorderSongs(
|
Future<void> reorderSongs(
|
||||||
|
|||||||
@@ -4,49 +4,62 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
import '../audio/player_controller.dart';
|
|
||||||
import '../audio/playback_state_manager.dart';
|
|
||||||
import '../services/audio_service.dart';
|
|
||||||
import '../utils/file_provider_utils.dart';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'playback_service.dart';
|
||||||
|
import 'audio_service.dart';
|
||||||
|
import '../utils/file_provider_utils.dart';
|
||||||
|
|
||||||
|
/// 音频服务处理器:连接 media_kit 播放引擎与 Android 系统媒体会话
|
||||||
|
/// 职责:发布播放状态到系统通知栏,接收系统媒体按钮事件
|
||||||
|
/// 它不拥有 Player 实例,所有播放控制均委托给 PlaybackService
|
||||||
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
||||||
final PlayerController _player = PlayerController();
|
final PlaybackService _playback = PlaybackService();
|
||||||
final PlaybackStateManager _state = PlaybackStateManager();
|
|
||||||
|
|
||||||
|
// 当前媒体信息
|
||||||
String? _currentId;
|
String? _currentId;
|
||||||
String? _currentTitle;
|
String? _currentTitle;
|
||||||
String? _currentArtist;
|
String? _currentArtist;
|
||||||
|
|
||||||
Duration _currentPosition = Duration.zero;
|
Duration _currentPosition = Duration.zero;
|
||||||
DateTime _lastPublishTime = DateTime.now();
|
DateTime _lastPublishTime = DateTime.now();
|
||||||
static const Duration _publishInterval = Duration(milliseconds: 500);
|
static const Duration _publishInterval = Duration(milliseconds: 500);
|
||||||
|
|
||||||
// ⭐ 缓存 artwork 文件路径,避免重复写入
|
|
||||||
String? _currentArtworkPath;
|
String? _currentArtworkPath;
|
||||||
|
bool _isPublishing = false;
|
||||||
|
|
||||||
AudioPlayerHandler() {
|
AudioPlayerHandler() {
|
||||||
_player.playingStream.listen((playing) {
|
_bindToPlayer();
|
||||||
_state.updatePlaying(playing);
|
}
|
||||||
_publishState();
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 绑定播放器状态流
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
void _bindToPlayer() {
|
||||||
|
final player = _playback.player;
|
||||||
|
|
||||||
|
player.stream.playing.listen((playing) {
|
||||||
|
_publishPlaybackState(
|
||||||
|
playing: playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
duration: player.state.duration,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
_player.positionStream.listen((position) {
|
player.stream.position.listen((position) {
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_state.updatePosition(position);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
||||||
_lastPublishTime = now;
|
_lastPublishTime = now;
|
||||||
// ⭐ 只保留 _publishStateOnly()
|
_publishPlaybackState(
|
||||||
_publishStateOnly();
|
playing: player.state.playing,
|
||||||
|
position: position,
|
||||||
|
duration: player.state.duration,
|
||||||
|
onlyPosition: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_player.durationStream.listen((duration) {
|
player.stream.duration.listen((duration) {
|
||||||
debugPrint('🎯 [durationStream] duration=$duration');
|
|
||||||
_state.updateDuration(duration);
|
|
||||||
if (_currentId != null && _currentTitle != null) {
|
if (_currentId != null && _currentTitle != null) {
|
||||||
_updateMediaItem(
|
_updateMediaItem(
|
||||||
id: _currentId!,
|
id: _currentId!,
|
||||||
@@ -55,45 +68,97 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_publishState();
|
_publishPlaybackState(
|
||||||
|
playing: player.state.playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 发布状态 ----
|
// ═══════════════════════════════════════════════════════════════
|
||||||
void _publishState() {
|
// 外部同步接口(由 AudioService 调用)
|
||||||
final state = _state.playbackState;
|
// ═══════════════════════════════════════════════════════════════
|
||||||
playbackState.add(audio_service.PlaybackState(
|
void syncState(Song? song) {
|
||||||
controls: state.controls,
|
if (song == null) {
|
||||||
processingState: state.processingState,
|
_currentId = null;
|
||||||
playing: state.playing,
|
_currentTitle = null;
|
||||||
androidCompactActionIndices: state.androidCompactActionIndices,
|
_currentArtist = null;
|
||||||
updatePosition: _currentPosition,
|
return;
|
||||||
updateTime: DateTime.now(),
|
|
||||||
systemActions: const {
|
|
||||||
audio_service.MediaAction.seek,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _publishStateOnly() {
|
_updateMediaItem(
|
||||||
final current = playbackState.value;
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
artwork: song.artwork,
|
||||||
|
);
|
||||||
|
|
||||||
playbackState.add(audio_service.PlaybackState(
|
final player = _playback.player;
|
||||||
controls: current.controls.isNotEmpty
|
_publishPlaybackState(
|
||||||
? current.controls
|
playing: player.state.playing,
|
||||||
: _state.playbackState.controls,
|
position: _currentPosition,
|
||||||
processingState: _state.playbackState.processingState,
|
duration: player.state.duration,
|
||||||
playing: current.playing,
|
);
|
||||||
androidCompactActionIndices: current.androidCompactActionIndices,
|
|
||||||
updatePosition: _currentPosition,
|
|
||||||
updateTime: DateTime.now(),
|
|
||||||
systemActions: const {
|
|
||||||
audio_service.MediaAction.seek,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 更新媒体信息 ----
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 状态发布
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
void _publishPlaybackState({
|
||||||
|
required bool playing,
|
||||||
|
required Duration position,
|
||||||
|
required Duration duration,
|
||||||
|
bool onlyPosition = false,
|
||||||
|
}) {
|
||||||
|
if (_isPublishing) return;
|
||||||
|
_isPublishing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final controls = _buildControls(playing);
|
||||||
|
// audio_service 侧映射:idle -> STATE_NONE,ready -> PLAYING/PAUSED。
|
||||||
|
// 之前只按 duration>0 判断,直播流/时长未解析出来时会被系统判定为"未在播放"
|
||||||
|
// (播放设备选择页、部分车机与耳机都会读到这个状态)。
|
||||||
|
final prepared = playing || duration.inMilliseconds > 0;
|
||||||
|
final state = audio_service.PlaybackState(
|
||||||
|
controls: controls,
|
||||||
|
processingState: prepared
|
||||||
|
? audio_service.AudioProcessingState.ready
|
||||||
|
: audio_service.AudioProcessingState.idle,
|
||||||
|
playing: playing,
|
||||||
|
updatePosition: position,
|
||||||
|
updateTime: DateTime.now(),
|
||||||
|
systemActions: const {audio_service.MediaAction.seek},
|
||||||
|
);
|
||||||
|
playbackState.add(state);
|
||||||
|
} finally {
|
||||||
|
_isPublishing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<audio_service.MediaControl> _buildControls(bool playing) {
|
||||||
|
return [
|
||||||
|
const audio_service.MediaControl(
|
||||||
|
androidIcon: 'drawable/ic_previous',
|
||||||
|
label: '上一曲',
|
||||||
|
action: audio_service.MediaAction.skipToPrevious,
|
||||||
|
),
|
||||||
|
audio_service.MediaControl(
|
||||||
|
androidIcon: playing ? 'drawable/ic_pause' : 'drawable/ic_play',
|
||||||
|
label: playing ? '暂停' : '播放',
|
||||||
|
action: audio_service.MediaAction.playPause,
|
||||||
|
),
|
||||||
|
const audio_service.MediaControl(
|
||||||
|
androidIcon: 'drawable/ic_next',
|
||||||
|
label: '下一曲',
|
||||||
|
action: audio_service.MediaAction.skipToNext,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 媒体信息更新
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
void _updateMediaItem({
|
void _updateMediaItem({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
@@ -101,21 +166,15 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
Uint8List? artwork,
|
Uint8List? artwork,
|
||||||
}) {
|
}) {
|
||||||
debugPrint(
|
|
||||||
'📢 [handler] _updateMediaItem: artwork is ${artwork != null ? 'not null (${artwork.length} bytes)' : 'null'}');
|
|
||||||
_currentId = id;
|
_currentId = id;
|
||||||
_currentTitle = title;
|
_currentTitle = title;
|
||||||
_currentArtist = artist;
|
_currentArtist = artist;
|
||||||
final position = _player.position;
|
|
||||||
|
|
||||||
debugPrint('📢 [handler] updateMediaItem: $title - $artist');
|
final position = _playback.player.state.position;
|
||||||
|
|
||||||
// ⭐ 异步处理 artwork(不阻塞主流程)
|
|
||||||
_handleArtwork(id, artwork).then((artUri) {
|
_handleArtwork(id, artwork).then((artUri) {
|
||||||
// 如果 artUri 变化,重新推送 MediaItem
|
|
||||||
final current = mediaItem.value;
|
final current = mediaItem.value;
|
||||||
if (current != null && current.artUri != artUri) {
|
if (current != null && current.artUri != artUri) {
|
||||||
debugPrint('📢 [handler] updating artUri: $artUri');
|
|
||||||
mediaItem.add(audio_service.MediaItem(
|
mediaItem.add(audio_service.MediaItem(
|
||||||
id: current.id,
|
id: current.id,
|
||||||
title: current.title,
|
title: current.title,
|
||||||
@@ -127,38 +186,28 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 先推送不带封面图的 MediaItem(让 UI 尽快显示)
|
|
||||||
mediaItem.add(audio_service.MediaItem(
|
mediaItem.add(audio_service.MediaItem(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
artist: artist,
|
artist: artist,
|
||||||
duration: duration ?? _player.duration,
|
duration: duration ?? _playback.player.state.duration,
|
||||||
extras: {'position': position.inMilliseconds},
|
extras: {'position': position.inMilliseconds},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理封面图:保存到本地并生成 content URI
|
|
||||||
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
||||||
if (artwork == null || artwork.isEmpty) {
|
if (artwork == null || artwork.isEmpty) return null;
|
||||||
_currentArtworkPath = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final artworkDir = Directory('${dir.path}/artworks');
|
final artworkDir = Directory('${dir.path}/artworks');
|
||||||
if (!await artworkDir.exists()) {
|
if (!await artworkDir.exists()) {
|
||||||
await artworkDir.create(recursive: true);
|
await artworkDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 md5 生成安全的文件名
|
|
||||||
final bytes = utf8.encode(id);
|
final bytes = utf8.encode(id);
|
||||||
final digest = md5.convert(bytes);
|
final digest = md5.convert(bytes);
|
||||||
final fileName = '$digest.jpg';
|
final fileName = '$digest.jpg';
|
||||||
final path = '${artworkDir.path}/$fileName';
|
final path = '${artworkDir.path}/$fileName';
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|
||||||
// 检查文件是否已存在
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
final existingBytes = await file.readAsBytes();
|
final existingBytes = await file.readAsBytes();
|
||||||
if (existingBytes.length == artwork.length &&
|
if (existingBytes.length == artwork.length &&
|
||||||
@@ -167,12 +216,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
return await FileProviderUtils.getContentUri(file);
|
return await FileProviderUtils.getContentUri(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入新文件
|
|
||||||
await file.writeAsBytes(artwork);
|
await file.writeAsBytes(artwork);
|
||||||
_currentArtworkPath = path;
|
_currentArtworkPath = path;
|
||||||
debugPrint('📢 [handler] artwork saved: $path');
|
|
||||||
|
|
||||||
return await FileProviderUtils.getContentUri(file);
|
return await FileProviderUtils.getContentUri(file);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
||||||
@@ -180,50 +225,55 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 外部接口 ----
|
|
||||||
void updateNotification({
|
void updateNotification({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
required String artist,
|
required String artist,
|
||||||
Uint8List? artwork,
|
Uint8List? artwork,
|
||||||
}) {
|
}) {
|
||||||
_updateMediaItem(
|
_updateMediaItem(id: id, title: title, artist: artist, artwork: artwork);
|
||||||
id: id,
|
final player = _playback.player;
|
||||||
title: title,
|
_publishPlaybackState(
|
||||||
artist: artist,
|
playing: player.state.playing,
|
||||||
artwork: artwork,
|
position: _currentPosition,
|
||||||
|
duration: player.state.duration,
|
||||||
);
|
);
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 控制命令 ----
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// audio_service 控制命令(全部委托给 PlaybackService)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
debugPrint('▶️ [handler] play() called');
|
debugPrint('▶️ [handler] play() called');
|
||||||
_player.play();
|
// 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
|
||||||
_publishState();
|
await AudioService().playOrResume();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
debugPrint('⏸️ [handler] pause() called');
|
debugPrint('⏸️ [handler] pause() called');
|
||||||
_player.pause();
|
await _playback.pause();
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
debugPrint('⏹️ [handler] stop() called');
|
debugPrint('⏹️ [handler] stop() called');
|
||||||
_player.stop();
|
await _playback.stop();
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
debugPrint('⏩ [handler] seek() called: $position');
|
debugPrint('⏩ [handler] seek() called: $position');
|
||||||
await _player.seek(position);
|
await _playback.seek(position);
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_publishStateOnly();
|
final player = _playback.player;
|
||||||
|
_publishPlaybackState(
|
||||||
|
playing: player.state.playing,
|
||||||
|
position: position,
|
||||||
|
duration: player.state.duration,
|
||||||
|
onlyPosition: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -251,14 +301,14 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
await skipToPrevious();
|
await skipToPrevious();
|
||||||
break;
|
break;
|
||||||
case audio_service.MediaButton.media:
|
case audio_service.MediaButton.media:
|
||||||
if (_player.isPlaying) {
|
if (_playback.player.state.playing) {
|
||||||
await pause();
|
await pause();
|
||||||
} else {
|
} else {
|
||||||
await play();
|
await play();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (_player.isPlaying) {
|
if (_playback.player.state.playing) {
|
||||||
await pause();
|
await pause();
|
||||||
} else {
|
} else {
|
||||||
await play();
|
await play();
|
||||||
|
|||||||
+626
-79
@@ -7,12 +7,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:media_kit/media_kit.dart' as media_kit;
|
import 'package:media_kit/media_kit.dart' as media_kit;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'playback_service.dart';
|
import 'playback_service.dart';
|
||||||
|
import 'network_watcher.dart';
|
||||||
|
import 'prefetch_service.dart';
|
||||||
import '../metadata/metadata_service.dart';
|
import '../metadata/metadata_service.dart';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
|
import '../utils/song_key.dart';
|
||||||
|
import '../utils/position_guard.dart';
|
||||||
import '../repositories/playlist_repository.dart';
|
import '../repositories/playlist_repository.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import 'webdav_service.dart';
|
import 'webdav_service.dart';
|
||||||
|
import 'audio_player_handler.dart';
|
||||||
|
|
||||||
enum PlayMode {
|
enum PlayMode {
|
||||||
sequential,
|
sequential,
|
||||||
@@ -27,12 +32,17 @@ class Song {
|
|||||||
final String? url;
|
final String? url;
|
||||||
final Uint8List? artwork;
|
final Uint8List? artwork;
|
||||||
|
|
||||||
|
/// 本地封面文件路径(songs.artwork_path)。
|
||||||
|
/// 命中过元数据的歌直接用它显示封面,不必等元数据流程回来。
|
||||||
|
final String? artworkPath;
|
||||||
|
|
||||||
Song({
|
Song({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.url,
|
this.url,
|
||||||
this.artwork,
|
this.artwork,
|
||||||
|
this.artworkPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -62,6 +72,9 @@ class AudioService extends ChangeNotifier {
|
|||||||
// ---- 当前播放的歌单 ID ----
|
// ---- 当前播放的歌单 ID ----
|
||||||
String? _currentPlaylistId;
|
String? _currentPlaylistId;
|
||||||
|
|
||||||
|
// ⭐ 插入位置:在现有成员变量之后,方法之前
|
||||||
|
AudioPlayerHandler? _handler; // ⭐ 添加这一行
|
||||||
|
|
||||||
// ---- 高频进度 ----
|
// ---- 高频进度 ----
|
||||||
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
|
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
|
||||||
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
|
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
|
||||||
@@ -76,6 +89,81 @@ class AudioService extends ChangeNotifier {
|
|||||||
bool _hasStartedCurrentPlayback = false;
|
bool _hasStartedCurrentPlayback = false;
|
||||||
bool _isUserSeeking = false;
|
bool _isUserSeeking = false;
|
||||||
|
|
||||||
|
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
|
||||||
|
int _switchEpoch = 0;
|
||||||
|
|
||||||
|
/// 卡死判定阈值 / 节流
|
||||||
|
static const Duration _stallThreshold = Duration(seconds: 20);
|
||||||
|
static const Duration _startStallThreshold = Duration(seconds: 10);
|
||||||
|
DateTime? _lastStallRecoveryAt;
|
||||||
|
|
||||||
|
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
|
||||||
|
DateTime? _lastBufferingAt;
|
||||||
|
|
||||||
|
/// 网络切换去抖
|
||||||
|
Timer? _networkDebounce;
|
||||||
|
|
||||||
|
/// 进度看门狗:某些卡死不会触发 buffering 事件(例如 CDN 半开连接),
|
||||||
|
/// 用「播放中进度长时间不动」兜底。
|
||||||
|
Timer? _progressTimer;
|
||||||
|
Duration _lastObservedPosition = Duration.zero;
|
||||||
|
DateTime _lastProgressAt = DateTime.now();
|
||||||
|
|
||||||
|
/// 最后已知的有效播放位置。
|
||||||
|
///
|
||||||
|
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
|
||||||
|
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
|
||||||
|
Duration _lastKnownPosition = Duration.zero;
|
||||||
|
|
||||||
|
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
|
||||||
|
bool _isRecovering = false;
|
||||||
|
|
||||||
|
/// 当前这首歌已经连续自动恢复了几次(成功后清零)。
|
||||||
|
/// 打开一首本身拿不到数据的歌时,看门狗会无限重试,
|
||||||
|
/// 必须熔断,否则日志和请求都会爆炸。
|
||||||
|
int _recoveryAttempts = 0;
|
||||||
|
static const int _maxRecoveryAttempts = 3;
|
||||||
|
|
||||||
|
/// 播放请求去重(同一次点击可能从多个入口触发)
|
||||||
|
DateTime? _lastPlayRequestAt;
|
||||||
|
int _lastPlayRequestIndex = -1;
|
||||||
|
|
||||||
|
/// 恢复退避:拥塞网络里连续失败后拉长间隔,避免和整栋楼抢带宽。
|
||||||
|
DateTime? _nextRecoveryAt;
|
||||||
|
Timer? _autoRetryTimer;
|
||||||
|
|
||||||
|
static Duration _backoffFor(int attempts) {
|
||||||
|
if (attempts <= 2) return Duration.zero;
|
||||||
|
if (attempts == 3) return const Duration(seconds: 30);
|
||||||
|
if (attempts == 4) return const Duration(seconds: 60);
|
||||||
|
return const Duration(seconds: 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前歌曲开始加载的时间,用于"起播看门狗":
|
||||||
|
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
|
||||||
|
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||||
|
DateTime? _trackStartAt;
|
||||||
|
DateTime? _playReadyAt;
|
||||||
|
int _prefetchedGeneration = -1;
|
||||||
|
|
||||||
|
/// 元数据回退到文件名时置位,播到 80% 再试一次
|
||||||
|
bool _metadataNeedsRetry = false;
|
||||||
|
int _metadataRetryGeneration = -1;
|
||||||
|
bool _firstAudioReported = false;
|
||||||
|
|
||||||
|
/// 冷启动恢复后尚未真正打开媒体:首次点击播放走 _playCurrent(含 seek)
|
||||||
|
bool _restoredPendingOpen = false;
|
||||||
|
|
||||||
|
/// 播放中定时持久化位置(冷启动能回到准确位置)
|
||||||
|
DateTime _lastPositionSaveAt = DateTime.now();
|
||||||
|
|
||||||
|
/// 打开失败后的快速重试(每首歌最多一次)
|
||||||
|
int _openRetryGeneration = -1;
|
||||||
|
|
||||||
|
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
|
||||||
|
String? _lastPlaybackError;
|
||||||
|
String? get lastPlaybackError => _lastPlaybackError;
|
||||||
|
|
||||||
int _playbackGeneration = 0;
|
int _playbackGeneration = 0;
|
||||||
|
|
||||||
// ---- 待恢复的播放进度 ----
|
// ---- 待恢复的播放进度 ----
|
||||||
@@ -115,6 +203,11 @@ class AudioService extends ChangeNotifier {
|
|||||||
_onSongChanged = callback;
|
_onSongChanged = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHandler(AudioPlayerHandler handler) {
|
||||||
|
// ⭐ 添加这个方法
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
void togglePlayMode() {
|
void togglePlayMode() {
|
||||||
switch (_playMode) {
|
switch (_playMode) {
|
||||||
case PlayMode.sequential:
|
case PlayMode.sequential:
|
||||||
@@ -207,21 +300,29 @@ class AudioService extends ChangeNotifier {
|
|||||||
final songs = <Song>[];
|
final songs = <Song>[];
|
||||||
for (final id in songIds) {
|
for (final id in songIds) {
|
||||||
final dbSong = await _db.getSongByPath(id);
|
final dbSong = await _db.getSongByPath(id);
|
||||||
if (dbSong != null) {
|
final path = (dbSong?['remote_path'] as String?) ?? id;
|
||||||
songs.add(Song(
|
|
||||||
id: dbSong['remote_path'] as String,
|
// 没有标签时回退文件名,必须解码:id 是百分号编码的播放路径,
|
||||||
title: dbSong['title'] as String? ?? '',
|
// 直接展示会变成 "%E6%9C%AA..." 这种不可读串。
|
||||||
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
final rawName = path.split('/').last;
|
||||||
url: dbSong['remote_path'] as String,
|
String displayName;
|
||||||
));
|
try {
|
||||||
} else {
|
displayName = Uri.decodeComponent(rawName);
|
||||||
songs.add(Song(
|
} catch (_) {
|
||||||
id: id,
|
displayName = rawName;
|
||||||
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
|
||||||
artist: '未知艺术家',
|
|
||||||
url: id,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
displayName = displayName.replaceAll(RegExp(r'\.[^.]*$'), '');
|
||||||
|
|
||||||
|
final dbTitle = (dbSong?['title'] as String?)?.trim() ?? '';
|
||||||
|
final dbArtist = (dbSong?['artist'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
|
songs.add(Song(
|
||||||
|
id: path,
|
||||||
|
artworkPath: dbSong?['artwork_path'] as String?,
|
||||||
|
title: dbTitle.isNotEmpty ? dbTitle : displayName,
|
||||||
|
artist: dbArtist.isNotEmpty ? dbArtist : '未知艺术家',
|
||||||
|
url: WebDAVService.instance.getFileUrl(path),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (songs.isNotEmpty) {
|
if (songs.isNotEmpty) {
|
||||||
@@ -243,19 +344,35 @@ class AudioService extends ChangeNotifier {
|
|||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
Future<void> _playCurrent() async {
|
Future<void> _playCurrent() async {
|
||||||
if (_isChangingTrack) {
|
|
||||||
debugPrint('⚠️ [AudioService] track switch already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
|
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
|
||||||
stopPlay();
|
stopPlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 去重:同一次点击可能从多个入口触发(列表 onTap + 控制器 + 通知),
|
||||||
|
// 反复 open 会互相顶掉,表现为"点了要等好几秒才出声"。
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastPlayRequestIndex == _currentIndex &&
|
||||||
|
_lastPlayRequestAt != null &&
|
||||||
|
now.difference(_lastPlayRequestAt!) < const Duration(milliseconds: 800)) {
|
||||||
|
debugPrint('⏭️ [AudioService] 忽略重复播放请求(index=$_currentIndex)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastPlayRequestAt = now;
|
||||||
|
_lastPlayRequestIndex = _currentIndex;
|
||||||
|
|
||||||
|
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
||||||
|
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
||||||
|
final epoch = ++_switchEpoch;
|
||||||
|
_restoredPendingOpen = false;
|
||||||
_isChangingTrack = true;
|
_isChangingTrack = true;
|
||||||
_handlingCompletion = false;
|
_handlingCompletion = false;
|
||||||
_hasStartedCurrentPlayback = false;
|
_hasStartedCurrentPlayback = false;
|
||||||
|
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
|
||||||
|
_lastKnownPosition = Duration.zero;
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_playbackGeneration++;
|
_playbackGeneration++;
|
||||||
@@ -264,6 +381,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
final song = _queue[_currentIndex];
|
final song = _queue[_currentIndex];
|
||||||
_currentSong = song;
|
_currentSong = song;
|
||||||
|
|
||||||
|
_handler?.syncState(song);
|
||||||
|
|
||||||
_startListening();
|
_startListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -271,9 +390,34 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_trackStartAt = DateTime.now();
|
||||||
|
_playReadyAt = null;
|
||||||
|
_firstAudioReported = false;
|
||||||
|
_metadataNeedsRetry = false;
|
||||||
await _playWithHeaders(song.url!);
|
await _playWithHeaders(song.url!);
|
||||||
|
_playReadyAt = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
// 播放失败(超时/401/网络中断):必须走到 finally 解锁,
|
||||||
|
// 并把错误记下来,供上层提示或下一首重试。
|
||||||
|
_lastPlaybackError = '播放失败: $e';
|
||||||
|
debugPrint('❌ [AudioService] play failed: $e');
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epoch != _switchEpoch) {
|
||||||
|
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _waitForPlaybackStarted();
|
await _waitForPlaybackStarted();
|
||||||
|
|
||||||
|
if (epoch != _switchEpoch) {
|
||||||
|
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ⭐ 如果有待恢复的进度,执行 seek
|
// ⭐ 如果有待恢复的进度,执行 seek
|
||||||
if (_pendingSeekPosition != null &&
|
if (_pendingSeekPosition != null &&
|
||||||
_pendingSeekPosition!.inMilliseconds > 0) {
|
_pendingSeekPosition!.inMilliseconds > 0) {
|
||||||
@@ -287,16 +431,331 @@ class AudioService extends ChangeNotifier {
|
|||||||
_syncPlayerStateDelayed();
|
_syncPlayerStateDelayed();
|
||||||
|
|
||||||
if (_hasStartedCurrentPlayback) {
|
if (_hasStartedCurrentPlayback) {
|
||||||
|
_lastPlaybackError = null;
|
||||||
_onSongChanged?.call(song);
|
_onSongChanged?.call(song);
|
||||||
_loadMetadataForCurrentSong(generation);
|
// 不在起播瞬间抓元数据:此时 mpv 正在填缓冲区,
|
||||||
|
// 2MB 的标签请求会和播放流抢带宽,实测会把"下一首"拖到 60s+。
|
||||||
|
_scheduleMetadataWhenSettled(generation, epoch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切歌完成后保存状态
|
// 切歌完成后保存状态
|
||||||
await savePlaybackState();
|
await savePlaybackState();
|
||||||
} finally {
|
} finally {
|
||||||
|
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
|
||||||
|
if (epoch == _switchEpoch) {
|
||||||
_isChangingTrack = false;
|
_isChangingTrack = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等播放真正稳定后再抓元数据。
|
||||||
|
///
|
||||||
|
/// 判定条件比"能出声"更严格:进度至少 5s、且连续 2s 不处于 buffering。
|
||||||
|
/// 原因:公网链路上新歌起播后 mpv 还在持续填缓冲,
|
||||||
|
/// 这时候插一个 2MB 的标签下载会直接把缓冲打回 0(实测下一首要等 60s+)。
|
||||||
|
/// 最多等 30s;等不到就放弃这次抓取(下次播放会再试)。
|
||||||
|
Future<bool> _waitForPlaybackSettled(int epoch,
|
||||||
|
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||||
|
final player = PlaybackService().player;
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
var quietChecks = 0;
|
||||||
|
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
if (epoch != _switchEpoch) return false;
|
||||||
|
|
||||||
|
final ready = player.state.playing &&
|
||||||
|
!player.state.buffering &&
|
||||||
|
player.state.position >= const Duration(seconds: 5);
|
||||||
|
|
||||||
|
if (ready) {
|
||||||
|
quietChecks++;
|
||||||
|
// 每 500ms 采一次,连续 4 次即稳定 2 秒
|
||||||
|
if (quietChecks >= 4) return true;
|
||||||
|
} else {
|
||||||
|
quietChecks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleMetadataWhenSettled(int generation, int epoch) {
|
||||||
|
// 故意不 await:不能阻塞切歌流程和状态保存
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
final settled = await _waitForPlaybackSettled(epoch);
|
||||||
|
if (!settled || epoch != _switchEpoch) {
|
||||||
|
debugPrint('⏸️ [AudioService] 播放未稳定,跳过本次元数据抓取(避免抢带宽)');
|
||||||
|
_metadataNeedsRetry = true; // 留到 80% 再试
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadMetadataForCurrentSong(generation);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [AudioService] 延迟元数据抓取失败: $e');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播到 80% 时,对"元数据回退到文件名"的歌再抓一次。
|
||||||
|
///
|
||||||
|
/// 原因:反代在等上游首包超时时会直接回 502(实测 10.3s),
|
||||||
|
/// 起播阶段抓取很容易撞上;到 80% 时链路通常已经热了,重试成功率更高。
|
||||||
|
/// 每首歌最多重试一次。
|
||||||
|
void _maybeRetryMetadata(Duration position) {
|
||||||
|
if (!_metadataNeedsRetry) return;
|
||||||
|
|
||||||
|
final duration = durationNotifier.value;
|
||||||
|
if (duration.inMilliseconds <= 0) return;
|
||||||
|
if (position.inMilliseconds < duration.inMilliseconds * 0.8) return;
|
||||||
|
if (_metadataRetryGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
|
_metadataRetryGeneration = _playbackGeneration;
|
||||||
|
_metadataNeedsRetry = false;
|
||||||
|
debugPrint('🔁 [AudioService] 播放到 80%,重试一次元数据抓取');
|
||||||
|
_loadMetadataForCurrentSong(_playbackGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播到 60% 时预热下一首(HEAD + Range 0-0,不下载正文)。
|
||||||
|
///
|
||||||
|
/// 日志实测起播延迟 2~3s 都在"等首批数据",提前完成 DNS/TLS/反代握手
|
||||||
|
/// 可以把这段时间压掉一部分;失败完全静默,不影响当前播放。
|
||||||
|
void _maybePrefetchNext(Duration position) {
|
||||||
|
final duration = durationNotifier.value;
|
||||||
|
if (duration.inSeconds < 60) return;
|
||||||
|
if (position.inMilliseconds < duration.inMilliseconds * 0.6) return;
|
||||||
|
if (_prefetchedGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
|
final next = _peekNextSong();
|
||||||
|
final url = next?.url;
|
||||||
|
if (url == null || url.isEmpty) return;
|
||||||
|
|
||||||
|
_prefetchedGeneration = _playbackGeneration;
|
||||||
|
debugPrint('🔥 [AudioService] 预热下一首:${next!.title}');
|
||||||
|
PrefetchService().warm(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只推算下一首,不改变队列/索引(next() 会改)。
|
||||||
|
Song? _peekNextSong() {
|
||||||
|
if (_queue.isEmpty) return null;
|
||||||
|
|
||||||
|
if (_playMode == PlayMode.shuffle) {
|
||||||
|
if (_shuffledIndices.isEmpty) return null;
|
||||||
|
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||||
|
return _queue[_shuffledIndices[nextIdx]];
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextIdx = (_currentIndex + 1) % _queue.length;
|
||||||
|
return _queue[nextIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
|
||||||
|
///
|
||||||
|
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
|
||||||
|
/// 每次 true 若都重置计时器,阈值永远不会到(实测公网链路抖动 60s+ 不恢复)。
|
||||||
|
/// 为什么不能只看进度:用户暂停时进度也不动,会误触发。
|
||||||
|
/// 两个条件同时满足即可区分。
|
||||||
|
void _checkPlaybackProgress() {
|
||||||
|
if (_isChangingTrack) {
|
||||||
|
// 切歌中不参与判断,重置基准
|
||||||
|
_lastObservedPosition = positionNotifier.value;
|
||||||
|
_lastProgressAt = DateTime.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final pos = positionNotifier.value;
|
||||||
|
if (pos != _lastObservedPosition) {
|
||||||
|
_lastObservedPosition = pos;
|
||||||
|
_lastProgressAt = now;
|
||||||
|
// 每 10s 持久化一次进度:进程被系统回收后,冷启动能回到准确位置
|
||||||
|
if (now.difference(_lastPositionSaveAt) >= const Duration(seconds: 10)) {
|
||||||
|
_lastPositionSaveAt = now;
|
||||||
|
savePlaybackState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final player = PlaybackService().player;
|
||||||
|
|
||||||
|
// 起播看门狗:playing=true 但 position 一直是 0,且没有任何 buffering/error 事件。
|
||||||
|
// 这种情况 mpv 不会自己恢复,通用 20s 规则又太慢,单独用 10s 阈值。
|
||||||
|
final stuckAtStart = pos == Duration.zero &&
|
||||||
|
_trackStartAt != null &&
|
||||||
|
now.difference(_trackStartAt!) >= _startStallThreshold &&
|
||||||
|
player.state.playing;
|
||||||
|
if (stuckAtStart) {
|
||||||
|
debugPrint('🩺 [AudioService] 起播 '
|
||||||
|
'${_startStallThreshold.inSeconds}s 仍停在 0s,触发重连');
|
||||||
|
_trackStartAt = now; // 避免下一次 tick 立刻重复触发
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stalledFor = now.difference(_lastProgressAt);
|
||||||
|
final recentlyBuffering = _lastBufferingAt != null &&
|
||||||
|
now.difference(_lastBufferingAt!) < const Duration(seconds: 20);
|
||||||
|
|
||||||
|
// playing==true 也是卡死的充分条件(暂停时 playing 为 false)
|
||||||
|
if (stalledFor >= _stallThreshold &&
|
||||||
|
(player.state.playing || recentlyBuffering)) {
|
||||||
|
debugPrint('🩺 [AudioService] 进度停在 $pos 已 ${stalledFor.inSeconds}s,'
|
||||||
|
'触发自愈');
|
||||||
|
_lastProgressAt = now;
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开失败(ffurl_read / Failed to open)后 1.5s 立即重试一次。
|
||||||
|
///
|
||||||
|
/// 实测:切歌后 mpv 在 5~6s 时放弃打开,旧逻辑要等起播看门狗
|
||||||
|
/// (5s 一跳 × 10s 阈值,最坏 20s)才重连,表现为十几秒的前摇。
|
||||||
|
/// 每首歌最多快速重试一次,避免失败风暴。
|
||||||
|
void _scheduleOpenRetry() {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
if (_openRetryGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
|
_openRetryGeneration = _playbackGeneration;
|
||||||
|
|
||||||
|
// 缓冲余量决定重试节奏:
|
||||||
|
// 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉;
|
||||||
|
// 余量见底 -> 尽快重连,尽量减少静音时间。
|
||||||
|
final bufferAhead = bufferedNotifier.value - positionNotifier.value;
|
||||||
|
final hasBuffer = bufferAhead >= const Duration(seconds: 12);
|
||||||
|
final delay = hasBuffer
|
||||||
|
? const Duration(seconds: 4)
|
||||||
|
: const Duration(milliseconds: 1200);
|
||||||
|
final baseline = _lastKnownPosition;
|
||||||
|
|
||||||
|
debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,'
|
||||||
|
'${delay.inMilliseconds}ms 后重试');
|
||||||
|
|
||||||
|
Timer(delay, () {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
// mpv 可能自己接上了:位置在推进就不打断它
|
||||||
|
if (positionNotifier.value >
|
||||||
|
baseline + const Duration(seconds: 1)) {
|
||||||
|
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冷却结束后的自动重试(只试一次,成功与否交给下一轮判断)
|
||||||
|
void _scheduleAutoRetry(Duration delay) {
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
_autoRetryTimer = Timer(delay, () {
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
if (positionNotifier.value > const Duration(seconds: 1)) return;
|
||||||
|
debugPrint('⏰ [AudioService] 冷却结束,再试一次');
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。
|
||||||
|
///
|
||||||
|
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
|
||||||
|
Future<void> _recoverFromStall({bool force = false}) async {
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
|
||||||
|
final song = _currentSong;
|
||||||
|
if (song == null) return;
|
||||||
|
final url = song.url;
|
||||||
|
if (url == null || url.isEmpty) return;
|
||||||
|
|
||||||
|
// 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。
|
||||||
|
// 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。
|
||||||
|
if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_recoveryAttempts >= _maxRecoveryAttempts) {
|
||||||
|
final delay = _backoffFor(_recoveryAttempts);
|
||||||
|
_nextRecoveryAt = DateTime.now().add(delay);
|
||||||
|
debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,'
|
||||||
|
'${delay.inSeconds}s 后再试:${song.title}');
|
||||||
|
if (_recoveryAttempts >= _maxRecoveryAttempts + 3) {
|
||||||
|
// 退避到极限:停住,交给用户手动重试(点播放会清零计数)
|
||||||
|
debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}');
|
||||||
|
_lastPlaybackError = '网络拥塞,无法播放:${song.title}';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scheduleAutoRetry(delay);
|
||||||
|
_recoveryAttempts++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recoveryAttempts++;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (!force &&
|
||||||
|
_lastStallRecoveryAt != null &&
|
||||||
|
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
|
||||||
|
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastStallRecoveryAt = now;
|
||||||
|
|
||||||
|
// 恢复前记下代次:await 期间用户可能已经切歌,
|
||||||
|
// 不能把旧歌重新打开(否则就是"点了新歌却切回旧歌")。
|
||||||
|
final recoveryGeneration = _playbackGeneration;
|
||||||
|
final resumeAt = _lastKnownPosition > Duration.zero
|
||||||
|
? _lastKnownPosition
|
||||||
|
: positionNotifier.value;
|
||||||
|
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
||||||
|
_isRecovering = true;
|
||||||
|
try {
|
||||||
|
_trackStartAt = DateTime.now();
|
||||||
|
_playReadyAt = null;
|
||||||
|
_firstAudioReported = false;
|
||||||
|
await _playWithHeaders(url);
|
||||||
|
if (_playbackGeneration != recoveryGeneration) {
|
||||||
|
debugPrint('⏭️ [AudioService] 恢复期间已切歌,放弃本次恢复');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_playReadyAt = DateTime.now();
|
||||||
|
if (resumeAt > Duration.zero) {
|
||||||
|
await PlaybackService().seek(resumeAt);
|
||||||
|
positionNotifier.value = resumeAt;
|
||||||
|
_lastKnownPosition = resumeAt;
|
||||||
|
}
|
||||||
|
debugPrint('✅ [AudioService] 自动恢复完成');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
||||||
|
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_isRecovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
|
||||||
|
///
|
||||||
|
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
|
||||||
|
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
|
||||||
|
void _onNetworkChanged() {
|
||||||
|
_networkDebounce?.cancel();
|
||||||
|
// 记录去抖开始时的歌:2 秒内用户可能已经切歌,
|
||||||
|
// 那时再去重连"上一首"就是异常切换(日志里出现过 78s 重连旧歌)。
|
||||||
|
final expectedSongId = _currentSong?.id;
|
||||||
|
final expectedGeneration = _playbackGeneration;
|
||||||
|
_networkDebounce = Timer(const Duration(seconds: 2), () {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
if (_currentSong?.id != expectedSongId ||
|
||||||
|
_playbackGeneration != expectedGeneration) {
|
||||||
|
debugPrint('🌐 [AudioService] 网络变化期间已切歌,忽略本次重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _waitForPlaybackStarted() async {
|
Future<void> _waitForPlaybackStarted() async {
|
||||||
final player = PlaybackService().player;
|
final player = PlaybackService().player;
|
||||||
@@ -340,9 +799,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
String _generateSongKey(String url) {
|
||||||
final raw = '$url|$fileSize|$modifiedTime';
|
return songKeyFor(url);
|
||||||
return raw.hashCode.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -394,12 +852,23 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.isNotEmpty) {
|
if (metadata.isNotEmpty) {
|
||||||
|
// 来源是文件名 = 元数据没读到(常见于反代 502 / 超时)
|
||||||
|
// 记下来,播到 80% 时再试一次,避免这首歌永远只有文件名
|
||||||
|
if (metadata.source == 'filename') {
|
||||||
|
_metadataNeedsRetry = true;
|
||||||
|
debugPrint('⚠️ [AudioService] 元数据回退到文件名,将在 80% 处重试一次');
|
||||||
|
} else {
|
||||||
|
_metadataNeedsRetry = false;
|
||||||
|
}
|
||||||
|
|
||||||
final updatedSong = Song(
|
final updatedSong = Song(
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: metadata.title.isNotEmpty ? metadata.title : song.title,
|
title: metadata.title.isNotEmpty ? metadata.title : song.title,
|
||||||
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
||||||
url: song.url,
|
url: song.url,
|
||||||
artwork: metadata.artwork,
|
artwork: metadata.artwork,
|
||||||
|
// 保留封面文件路径:元数据流程回来前,播放页靠它立即显示封面
|
||||||
|
artworkPath: song.artworkPath,
|
||||||
);
|
);
|
||||||
_queue[_currentIndex] = updatedSong;
|
_queue[_currentIndex] = updatedSong;
|
||||||
_currentSong = updatedSong;
|
_currentSong = updatedSong;
|
||||||
@@ -431,13 +900,12 @@ class AudioService extends ChangeNotifier {
|
|||||||
final file = await provider.getFile(url);
|
final file = await provider.getFile(url);
|
||||||
if (file != null && await file.exists()) {
|
if (file != null && await file.exists()) {
|
||||||
final stat = await file.stat();
|
final stat = await file.stat();
|
||||||
songKey = _generateSongKey(
|
songKey = _generateSongKey(url);
|
||||||
url, stat.size, stat.modified.millisecondsSinceEpoch);
|
|
||||||
} else {
|
} else {
|
||||||
songKey = url.hashCode.toString();
|
songKey = songKeyForUrl(url);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
songKey = url.hashCode.toString();
|
songKey = songKeyForUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
final db = SongDatabase();
|
final db = SongDatabase();
|
||||||
@@ -450,7 +918,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final cacheDir = await getTemporaryDirectory();
|
final cacheDir = await getTemporaryDirectory();
|
||||||
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
|
||||||
final cacheFile = File(cachePath);
|
final cacheFile = File(cachePath);
|
||||||
if (await cacheFile.exists()) {
|
if (await cacheFile.exists()) {
|
||||||
await cacheFile.delete();
|
await cacheFile.delete();
|
||||||
@@ -483,11 +951,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isChangingTrack) {
|
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
|
||||||
debugPrint('⚠️ [next] REJECTED: track switch in progress');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_playMode == PlayMode.shuffle) {
|
if (_playMode == PlayMode.shuffle) {
|
||||||
if (_shuffledIndices.isEmpty) return;
|
if (_shuffledIndices.isEmpty) return;
|
||||||
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||||
@@ -505,12 +969,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
Future<void> previous() async {
|
Future<void> previous() async {
|
||||||
if (_queue.isEmpty) return;
|
if (_queue.isEmpty) return;
|
||||||
|
|
||||||
if (_isChangingTrack) {
|
// 同 next():最新请求胜出,不拒绝
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AudioService] previous() ignored: track switch in progress');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_playMode == PlayMode.shuffle) {
|
if (_playMode == PlayMode.shuffle) {
|
||||||
if (_shuffledIndices.isEmpty) return;
|
if (_shuffledIndices.isEmpty) return;
|
||||||
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
|
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
|
||||||
@@ -539,14 +998,39 @@ class AudioService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (_isPlaying) {
|
if (_isPlaying) {
|
||||||
PlaybackService().pause();
|
PlaybackService().pause();
|
||||||
} else {
|
savePlaybackState();
|
||||||
PlaybackService().resume();
|
return;
|
||||||
}
|
}
|
||||||
// 保存状态(包括进度)
|
|
||||||
|
playOrResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一的"继续播放"入口:UI 按钮、通知栏、耳机键都走这里。
|
||||||
|
///
|
||||||
|
/// 冷启动恢复后音频尚未加载,此时 resume() 是在空播放器上调用,
|
||||||
|
/// 表现就是"点了没反应"。这种情况改走 _playCurrent(open + seek + play)。
|
||||||
|
Future<void> playOrResume() async {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isPlaying) return;
|
||||||
|
|
||||||
|
// 用户手动点播放:重置退避计数,允许立即重试
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
|
||||||
|
if (_restoredPendingOpen) {
|
||||||
|
_restoredPendingOpen = false;
|
||||||
|
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
|
||||||
|
await _playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PlaybackService().resume();
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopPlay() {
|
void stopPlay() {
|
||||||
|
// 停止底层播放器
|
||||||
|
PlaybackService().stop(); // 新增
|
||||||
_currentSong = null;
|
_currentSong = null;
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
positionNotifier.value = Duration.zero;
|
positionNotifier.value = Duration.zero;
|
||||||
@@ -554,7 +1038,6 @@ class AudioService extends ChangeNotifier {
|
|||||||
bufferedNotifier.value = Duration.zero;
|
bufferedNotifier.value = Duration.zero;
|
||||||
_stopListening();
|
_stopListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
// 停止时也保存一次
|
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +1117,47 @@ class AudioService extends ChangeNotifier {
|
|||||||
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 位置回退保护:打开失败/重连时 mpv 会短暂报 0。
|
||||||
|
// 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s,
|
||||||
|
// 否则界面进度会归零,恢复时也会"从头播"。
|
||||||
|
if (shouldIgnorePositionRegression(
|
||||||
|
lastKnown: _lastKnownPosition,
|
||||||
|
incoming: position,
|
||||||
|
isChangingTrack: _isChangingTrack,
|
||||||
|
isRecovering: _isRecovering,
|
||||||
|
)) {
|
||||||
|
debugPrint('🛡️ [AudioService] 忽略位置回退: '
|
||||||
|
'${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position > _lastKnownPosition ||
|
||||||
|
_isChangingTrack ||
|
||||||
|
_isRecovering) {
|
||||||
|
_lastKnownPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真的出声了(>1s),说明恢复成功,重置熔断计数
|
||||||
|
if (position > const Duration(seconds: 1)) {
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
||||||
|
// 与 play() 发起时间相减即可算出起播延迟。
|
||||||
|
if (!_firstAudioReported && position > Duration.zero) {
|
||||||
|
_firstAudioReported = true;
|
||||||
|
final readyAt = _playReadyAt;
|
||||||
|
if (readyAt != null) {
|
||||||
|
final ms = DateTime.now().difference(readyAt).inMilliseconds;
|
||||||
|
debugPrint('🔊 [AudioService] 起播耗时 ${ms}ms(open 完成 -> 出声)');
|
||||||
|
_playReadyAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_maybeRetryMetadata(position);
|
||||||
|
_maybePrefetchNext(position);
|
||||||
positionNotifier.value = position;
|
positionNotifier.value = position;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -653,6 +1177,47 @@ class AudioService extends ChangeNotifier {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
|
||||||
|
_subscriptions.add(
|
||||||
|
player.stream.error.listen((error) {
|
||||||
|
debugPrint('❌ [AudioService] player error: $error');
|
||||||
|
_lastPlaybackError = error;
|
||||||
|
notifyListeners();
|
||||||
|
// 打开失败(ffurl_read / Failed to open)时立刻安排一次重试,
|
||||||
|
// 不要等 10~20s 的起播看门狗。
|
||||||
|
if (error.contains('Failed to open') ||
|
||||||
|
error.contains('ffurl_read')) {
|
||||||
|
_scheduleOpenRetry();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缓冲状态:只记录"最近一次进入缓冲的时间"。
|
||||||
|
// 不能用 Timeout 重置计时:弱网下 buffering 会 true/false 反复抖动,
|
||||||
|
// 每次 true 都重置的话,30s 阈值永远不会到。
|
||||||
|
_subscriptions.add(
|
||||||
|
player.stream.buffering.listen((buffering) {
|
||||||
|
debugPrint('🌊 [AudioService] buffering=$buffering');
|
||||||
|
if (buffering) {
|
||||||
|
_lastBufferingAt = DateTime.now();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 进度看门狗:播放中进度 30s 不动、且近期发生过缓冲 → 判定卡死并自愈。
|
||||||
|
// 用"进度 + 最近缓冲"两个条件,既不会在暂停时误触发,
|
||||||
|
// 也能覆盖 buffering 反复抖动、始终起不来的情况。
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_lastProgressAt = DateTime.now();
|
||||||
|
_progressTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
(_) => _checkPlaybackProgress(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 网络变化监听:换 Wi-Fi / 切移动数据后长连接会失效,主动重连
|
||||||
|
NetworkWatcher().onChanged = _onNetworkChanged;
|
||||||
|
NetworkWatcher().start();
|
||||||
|
|
||||||
// ⭐ 完整生命周期校验的 completed 监听
|
// ⭐ 完整生命周期校验的 completed 监听
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
player.stream.completed.listen((_) {
|
player.stream.completed.listen((_) {
|
||||||
@@ -821,11 +1386,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
_currentSong = song;
|
_currentSong = song;
|
||||||
_onSongChanged?.call(song);
|
_onSongChanged?.call(song);
|
||||||
|
|
||||||
|
_handler?.syncState(song); // ⭐ 添加这一行
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
|
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
|
||||||
|
|
||||||
// ⭐ 关键:加载音频但不自动播放
|
// 冷启动只恢复 UI 状态,不联网预加载。
|
||||||
await _loadRestoredPlayback();
|
// 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
|
||||||
|
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
|
||||||
|
_restoredPendingOpen = true;
|
||||||
|
_isPlaying = false;
|
||||||
|
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -834,44 +1406,19 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ⭐ 加载恢复的播放状态(加载音频,不自动播放)
|
// 说明:冷启动不再预加载音频(原 _loadRestoredPlayback)。
|
||||||
Future<void> _loadRestoredPlayback() async {
|
// 恢复只做 UI 状态,首次播放时由 togglePlay → _playCurrent 完成 open + seek。
|
||||||
if (_queue.isEmpty || _currentIndex < 0) return;
|
|
||||||
final song = _queue[_currentIndex];
|
|
||||||
if (song.url == null || song.url!.isEmpty) return;
|
|
||||||
|
|
||||||
// 启动监听
|
|
||||||
_startListening();
|
|
||||||
|
|
||||||
// 加载音频(带 headers)
|
|
||||||
final headers = await _getAuthHeadersForUrl(song.url!);
|
|
||||||
await PlaybackService().play(song.url!, headers: headers);
|
|
||||||
|
|
||||||
// 等待播放器准备好
|
|
||||||
await _waitForPlaybackStarted();
|
|
||||||
|
|
||||||
// 如果有待恢复的进度,执行 seek
|
|
||||||
if (_pendingSeekPosition != null &&
|
|
||||||
_pendingSeekPosition!.inMilliseconds > 0) {
|
|
||||||
final pos = _pendingSeekPosition!;
|
|
||||||
_pendingSeekPosition = null;
|
|
||||||
await PlaybackService().seek(pos);
|
|
||||||
positionNotifier.value = pos;
|
|
||||||
debugPrint('🎯 [AudioService] restored position: $pos');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ 加载完成后立即暂停(用户点击播放按钮后才继续)
|
|
||||||
await PlaybackService().pause();
|
|
||||||
_isPlaying = false;
|
|
||||||
_hasStartedCurrentPlayback = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
debugPrint('🎵 [AudioService] restored playback loaded and paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// 先保存再清理
|
// 先保存再清理
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_progressTimer = null;
|
||||||
|
_networkDebounce?.cancel();
|
||||||
|
_networkDebounce = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
_autoRetryTimer = null;
|
||||||
|
NetworkWatcher().stop();
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
positionNotifier.dispose();
|
positionNotifier.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// lib/services/favorite_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 收藏状态变化事件。
|
||||||
|
///
|
||||||
|
/// 文件列表、播放页都能收藏/取消收藏,主页的收藏列表需要跟着变,
|
||||||
|
/// 否则要退出重进才看得到。
|
||||||
|
class FavoriteEvents {
|
||||||
|
static final FavoriteEvents _instance = FavoriteEvents._internal();
|
||||||
|
factory FavoriteEvents() => _instance;
|
||||||
|
FavoriteEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<String> _controller =
|
||||||
|
StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
Stream<String> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish(String songPath) {
|
||||||
|
if (songPath.isEmpty) return;
|
||||||
|
_controller.add(songPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// lib/services/metadata_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 一条元数据更新(path + songs 表的最新行)
|
||||||
|
class MetadataUpdate {
|
||||||
|
final String path;
|
||||||
|
final Map<String, dynamic> row;
|
||||||
|
|
||||||
|
const MetadataUpdate(this.path, this.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据更新事件总线。
|
||||||
|
///
|
||||||
|
/// 之前各列表页靠"返回时重查"来刷新,歌曲补全标签后要退出重进才看得到。
|
||||||
|
/// 现在 MetadataService 写库成功后 publish,订阅的页面按 path 局部更新。
|
||||||
|
class MetadataEvents {
|
||||||
|
static final MetadataEvents _instance = MetadataEvents._internal();
|
||||||
|
factory MetadataEvents() => _instance;
|
||||||
|
MetadataEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<MetadataUpdate> _controller =
|
||||||
|
StreamController<MetadataUpdate>.broadcast();
|
||||||
|
|
||||||
|
Stream<MetadataUpdate> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish(String path, Map<String, dynamic> row) {
|
||||||
|
if (path.isEmpty) return;
|
||||||
|
_controller.add(MetadataUpdate(path, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// lib/services/network_watcher.dart
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 监听本机网络接口的地址变化。
|
||||||
|
///
|
||||||
|
/// 为什么不直接依赖 mpv 自己恢复:HTTP 流是长连接,Wi-Fi 切换 / 移动数据切换
|
||||||
|
/// 会换掉出口 IP,旧连接直接失效。mpv 不一定会重连,通常表现为
|
||||||
|
/// "进度不动、缓冲一直转"。所以在网络变化时由上层主动重开当前歌曲。
|
||||||
|
///
|
||||||
|
/// 实现方式:每 10s 枚举一次 IPv4 地址,签名变化即视为网络切换。
|
||||||
|
/// 相比平台 Channel 的方案,它不需要新增权限或插件,
|
||||||
|
/// 且能覆盖"同一个 Wi-Fi 换了 AP / 换了出口"这类 transport 类型不变的情况。
|
||||||
|
class NetworkWatcher {
|
||||||
|
static final NetworkWatcher _instance = NetworkWatcher._internal();
|
||||||
|
factory NetworkWatcher() => _instance;
|
||||||
|
NetworkWatcher._internal();
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
String? _lastSignature;
|
||||||
|
bool _ticking = false;
|
||||||
|
|
||||||
|
/// 网络签名变化时回调(已在内部做过去抖)
|
||||||
|
void Function()? onChanged;
|
||||||
|
|
||||||
|
void start({Duration interval = const Duration(seconds: 3)}) {
|
||||||
|
if (_timer != null) return;
|
||||||
|
_timer = Timer.periodic(interval, (_) => _tick());
|
||||||
|
_tick();
|
||||||
|
debugPrint('🌐 [NetworkWatcher] 已启动(每 ${interval.inSeconds}s 检查一次)');
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_lastSignature = null;
|
||||||
|
debugPrint('🌐 [NetworkWatcher] 已停止');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tick() async {
|
||||||
|
if (_ticking) return;
|
||||||
|
_ticking = true;
|
||||||
|
try {
|
||||||
|
final interfaces = await NetworkInterface.list(
|
||||||
|
type: InternetAddressType.IPv4,
|
||||||
|
includeLoopback: false,
|
||||||
|
);
|
||||||
|
final signature = interfaces
|
||||||
|
.map((i) => '${i.name}:${i.addresses.map((a) => a.address).join(',')}')
|
||||||
|
.join('|');
|
||||||
|
|
||||||
|
final previous = _lastSignature;
|
||||||
|
_lastSignature = signature;
|
||||||
|
|
||||||
|
// 首次只记录基线,不触发
|
||||||
|
if (previous != null && previous != signature) {
|
||||||
|
debugPrint('🌐 [NetworkWatcher] 检测到网络变化');
|
||||||
|
onChanged?.call();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [NetworkWatcher] 枚举网络接口失败: $e');
|
||||||
|
} finally {
|
||||||
|
_ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
// lib/services/playback_service.dart
|
// lib/services/playback_service.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// PlaybackService - 播放引擎封装
|
|
||||||
// 职责:管理 Player 生命周期,不涉及业务逻辑
|
|
||||||
// ============================================================
|
|
||||||
class PlaybackService {
|
class PlaybackService {
|
||||||
static final PlaybackService _instance = PlaybackService._internal();
|
static final PlaybackService _instance = PlaybackService._internal();
|
||||||
factory PlaybackService() => _instance;
|
factory PlaybackService() => _instance;
|
||||||
PlaybackService._internal();
|
PlaybackService._internal() {
|
||||||
|
// ⭐ 给单例本身打上 ID
|
||||||
|
_serviceId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||||
|
debugPrint('🎯 [PlaybackService] 单例创建, serviceId=$_serviceId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 弱网/源站抖动时,mpv 的 open 可能长时间不返回。
|
||||||
|
/// 不设上限的话 _playCurrent() 的 finally 永远不执行,
|
||||||
|
/// _isChangingTrack 会永久为 true,整个切歌链路假死。
|
||||||
|
static const Duration _openTimeout = Duration(seconds: 20);
|
||||||
|
static const Duration _controlTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
Player? _player;
|
Player? _player;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
late final String _serviceId; // ⭐ 服务实例 ID
|
||||||
|
|
||||||
// ---------- Getter ----------
|
// ---------- Getter ----------
|
||||||
Player get player {
|
Player get player {
|
||||||
|
debugPrint(
|
||||||
|
'🎯 [PlaybackService] get player called, serviceId=$_serviceId, initialized=$_initialized, player=${_player?.hashCode}');
|
||||||
if (!_initialized) init();
|
if (!_initialized) init();
|
||||||
return _player!;
|
return _player!;
|
||||||
}
|
}
|
||||||
@@ -23,48 +35,105 @@ class PlaybackService {
|
|||||||
|
|
||||||
// ---------- 初始化 ----------
|
// ---------- 初始化 ----------
|
||||||
void init() {
|
void init() {
|
||||||
if (_initialized) return;
|
if (_initialized) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [PlaybackService] init() 被重复调用, 跳过, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_initialized = true;
|
|
||||||
|
|
||||||
// 可以在这里配置初始参数
|
// 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
|
||||||
// _player.setVolume(1.0);
|
// 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
|
||||||
|
// 通过 platform 动态调用,避免依赖 media_kit 的内部类型。
|
||||||
|
try {
|
||||||
|
final platform = _player!.platform as dynamic;
|
||||||
|
platform.setProperty('cache-secs', '60');
|
||||||
|
platform.setProperty('demuxer-max-bytes', '64MiB');
|
||||||
|
platform.setProperty('demuxer-readahead-secs', '30');
|
||||||
|
debugPrint('✅ [PlaybackService] mpv 缓冲参数已设置(cache 60s / 64MiB)');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [PlaybackService] 设置 mpv 缓冲参数失败: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
debugPrint(
|
||||||
|
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 播放 ----------
|
// ---------- 播放 ----------
|
||||||
Future<void> play(String url, {Map<String, String>? headers}) async {
|
Future<void> play(String url, {Map<String, String>? headers}) async {
|
||||||
|
debugPrint(
|
||||||
|
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}, url=${_shortUrl(url)}');
|
||||||
if (!_initialized) init();
|
if (!_initialized) init();
|
||||||
|
|
||||||
final media = (headers != null && headers.isNotEmpty)
|
final media = (headers != null && headers.isNotEmpty)
|
||||||
? Media(url, httpHeaders: headers)
|
? Media(url, httpHeaders: headers)
|
||||||
: Media(url);
|
: Media(url);
|
||||||
|
|
||||||
await _player!.open(media);
|
try {
|
||||||
await _player!.play();
|
final sw = Stopwatch()..start();
|
||||||
|
await _player!.open(media).timeout(_openTimeout);
|
||||||
|
final openMs = sw.elapsedMilliseconds;
|
||||||
|
await _player!.play().timeout(_controlTimeout);
|
||||||
|
debugPrint('⏱️ [PlaybackService] open 完成 ${openMs}ms,'
|
||||||
|
'play 完成 ${sw.elapsedMilliseconds}ms');
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [PlaybackService] open timeout (${_openTimeout.inSeconds}s): ${_shortUrl(url)}');
|
||||||
|
await _safeStop();
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PlaybackService] play failed: $e');
|
||||||
|
await _safeStop();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败时尽量把播放器拉回可用的空闲状态,避免下一次 open 继续卡住。
|
||||||
|
Future<void> _safeStop() async {
|
||||||
|
try {
|
||||||
|
await _player?.stop().timeout(const Duration(seconds: 3));
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略:stop 本身失败也不应影响上层解锁
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志里不打完整 URL(WebDAV 路径很长,且可能带签名参数)
|
||||||
|
String _shortUrl(String url) {
|
||||||
|
final hash = url.hashCode.toRadixString(16);
|
||||||
|
return url.length <= 48 ? url : '${url.substring(0, 48)}…#$hash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 控制 ----------
|
// ---------- 控制 ----------
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
|
debugPrint(
|
||||||
|
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
if (_initialized && _player != null) {
|
if (_initialized && _player != null) {
|
||||||
await _player!.pause();
|
await _player!.pause().timeout(_controlTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resume() async {
|
Future<void> resume() async {
|
||||||
|
debugPrint(
|
||||||
|
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
if (_initialized && _player != null) {
|
if (_initialized && _player != null) {
|
||||||
await _player!.play();
|
await _player!.play().timeout(_controlTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
|
debugPrint(
|
||||||
|
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
if (_initialized && _player != null) {
|
if (_initialized && _player != null) {
|
||||||
await _player!.stop();
|
await _player!.stop().timeout(_controlTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
|
debugPrint(
|
||||||
|
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
if (_initialized && _player != null) {
|
if (_initialized && _player != null) {
|
||||||
await _player!.seek(position);
|
await _player!.seek(position).timeout(_controlTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +145,8 @@ class PlaybackService {
|
|||||||
|
|
||||||
// ---------- 释放 ----------
|
// ---------- 释放 ----------
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
debugPrint(
|
||||||
|
'🗑️ [PlaybackService] dispose() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||||
if (_initialized && _player != null) {
|
if (_initialized && _player != null) {
|
||||||
_player!.dispose();
|
_player!.dispose();
|
||||||
_player = null;
|
_player = null;
|
||||||
@@ -83,8 +154,10 @@ class PlaybackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 重新初始化(用于 Hot Restart 场景) ----------
|
// ---------- 重新初始化 ----------
|
||||||
void reinitialize() {
|
void reinitialize() {
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [PlaybackService] reinitialize() called, serviceId=$_serviceId');
|
||||||
dispose();
|
dispose();
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
|
||||||
class PlayerService {
|
|
||||||
static final PlayerService _instance = PlayerService._internal();
|
|
||||||
|
|
||||||
factory PlayerService() => _instance;
|
|
||||||
|
|
||||||
PlayerService._internal();
|
|
||||||
|
|
||||||
Player? _player;
|
|
||||||
|
|
||||||
Player get player {
|
|
||||||
return _player ??= Player();
|
|
||||||
}
|
|
||||||
|
|
||||||
void play(String url) {
|
|
||||||
player.open(Media(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
void pause() => player.pause();
|
|
||||||
|
|
||||||
void resume() => player.play();
|
|
||||||
|
|
||||||
void stop() => player.stop();
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_player?.dispose();
|
|
||||||
_player = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态监听...
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// lib/services/playlist_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 歌单数据变更事件(新建/重命名/删除/加歌/删歌/排序)。
|
||||||
|
///
|
||||||
|
/// 主页的歌单页、歌单详情页都靠它实时刷新,
|
||||||
|
/// 不用等页面重建。
|
||||||
|
class PlaylistEvents {
|
||||||
|
static final PlaylistEvents _instance = PlaylistEvents._internal();
|
||||||
|
factory PlaylistEvents() => _instance;
|
||||||
|
PlaylistEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<void> _controller =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
Stream<void> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish() {
|
||||||
|
_controller.add(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// lib/services/prefetch_service.dart
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'webdav_service.dart';
|
||||||
|
|
||||||
|
/// 下一首预热。
|
||||||
|
///
|
||||||
|
/// 目的:在用户真正切歌之前,提前完成 DNS 解析、TLS 握手、反代/CDN 的首包,
|
||||||
|
/// 让 mpv 起播时少等一会儿。
|
||||||
|
///
|
||||||
|
/// 只发 HEAD + `Range: bytes=0-0`,不下载正文,带宽开销可以忽略;
|
||||||
|
/// 失败也完全静默,不影响当前播放。
|
||||||
|
class PrefetchService {
|
||||||
|
static final PrefetchService _instance = PrefetchService._internal();
|
||||||
|
factory PrefetchService() => _instance;
|
||||||
|
PrefetchService._internal();
|
||||||
|
|
||||||
|
final Dio _dio = Dio();
|
||||||
|
|
||||||
|
String? _lastUrl;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> warm(String url) async {
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
if (_busy) return;
|
||||||
|
if (_lastUrl == url) return;
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
_lastUrl = url;
|
||||||
|
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
|
if (headers.isEmpty) return;
|
||||||
|
|
||||||
|
final response = await _dio.head(
|
||||||
|
url,
|
||||||
|
options: Options(
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Range': 'bytes=0-0',
|
||||||
|
},
|
||||||
|
receiveTimeout: const Duration(seconds: 8),
|
||||||
|
sendTimeout: const Duration(seconds: 5),
|
||||||
|
// HEAD 返回 4xx/5xx 也不抛异常,交给日志记录
|
||||||
|
validateStatus: (_) => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('🔥 [Prefetch] 预热完成 ${sw.elapsedMilliseconds}ms,'
|
||||||
|
'status=${response.statusCode}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Prefetch] 预热失败(忽略): $e');
|
||||||
|
} finally {
|
||||||
|
_busy = false;
|
||||||
|
// 允许同一首歌在下一轮重新预热(比如重播)
|
||||||
|
if (_lastUrl == url) {
|
||||||
|
// 保留 _lastUrl 做去重,下一次换歌时会被覆盖
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切歌/停止时清掉去重状态
|
||||||
|
void reset() {
|
||||||
|
_lastUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// lib/services/route_manager.dart
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 路由管理器(控制 MiniPlayer 显示/隐藏)
|
||||||
|
class RouteManager extends ChangeNotifier {
|
||||||
|
static final RouteManager _instance = RouteManager._internal();
|
||||||
|
factory RouteManager() => _instance;
|
||||||
|
RouteManager._internal();
|
||||||
|
|
||||||
|
String _currentRoute = '/';
|
||||||
|
String get currentRoute => _currentRoute;
|
||||||
|
|
||||||
|
/// 弹层/对话框打开时临时隐藏迷你播放器,避免盖住内容
|
||||||
|
bool _temporarilyHidden = false;
|
||||||
|
|
||||||
|
bool get showMiniPlayer =>
|
||||||
|
!_temporarilyHidden &&
|
||||||
|
_currentRoute != '/player' &&
|
||||||
|
_currentRoute != '/playlist';
|
||||||
|
|
||||||
|
void setTemporarilyHidden(bool hidden) {
|
||||||
|
if (_temporarilyHidden == hidden) return;
|
||||||
|
_temporarilyHidden = hidden;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateRoute(String route) {
|
||||||
|
if (_currentRoute != route) {
|
||||||
|
_currentRoute = route;
|
||||||
|
debugPrint('📌 路由更新: $route');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// lib/utils/position_guard.dart
|
||||||
|
|
||||||
|
/// 位置回退保护。
|
||||||
|
///
|
||||||
|
/// 打开失败 / 重连瞬间,mpv 会短暂把 position 报成 0 或几秒。
|
||||||
|
/// 如果直接采用,界面进度会归零,恢复时也会"从头播"。
|
||||||
|
///
|
||||||
|
/// 规则:只有在"已经播过一段时间"且"新位置几乎归零"时才算异常回退;
|
||||||
|
/// 用户 seek、切歌、我们自己的恢复流程都允许回退,不拦截。
|
||||||
|
bool shouldIgnorePositionRegression({
|
||||||
|
required Duration lastKnown,
|
||||||
|
required Duration incoming,
|
||||||
|
required bool isChangingTrack,
|
||||||
|
required bool isRecovering,
|
||||||
|
}) {
|
||||||
|
if (isChangingTrack || isRecovering) return false;
|
||||||
|
if (lastKnown <= const Duration(seconds: 10)) return false;
|
||||||
|
if (incoming >= const Duration(seconds: 5)) return false;
|
||||||
|
return (lastKnown - incoming) > const Duration(seconds: 5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// lib/utils/song_key.dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// 歌曲缓存键(song_key)。
|
||||||
|
///
|
||||||
|
/// 只用 URL:元数据读取是"分阶段取头部"的,第一次可能只取 128KB、
|
||||||
|
/// 第二次补到 512KB。如果键里带文件大小,两次会得到不同的键,缓存立刻失效;
|
||||||
|
/// URL 在这些阶段之间是稳定的。
|
||||||
|
///
|
||||||
|
/// 代价:同一个 URL 换了文件内容时不会自动失效,由"清除单曲缓存 /
|
||||||
|
/// forceRefresh"兜底。
|
||||||
|
String songKeyFor(String url) {
|
||||||
|
return md5.convert(utf8.encode(url)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 语义化别名,用于临时文件命名等场景。
|
||||||
|
String songKeyForUrl(String url) => songKeyFor(url);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// lib/widgets/add_to_playlist_sheet.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import '../services/route_manager.dart';
|
||||||
|
|
||||||
|
/// 把一首歌加入歌单的底部弹层。
|
||||||
|
///
|
||||||
|
/// 视觉先保持系统样式,等设计定稿后只换这个文件即可,
|
||||||
|
/// 调用方(文件列表长按)不用改。
|
||||||
|
Future<void> showAddToPlaylistSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String songPath,
|
||||||
|
}) async {
|
||||||
|
final repo = PlaylistRepository();
|
||||||
|
List<PlaylistSummary> playlists;
|
||||||
|
try {
|
||||||
|
playlists = await repo.getSummaries();
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('读取歌单失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
RouteManager().setTemporarilyHidden(true);
|
||||||
|
try {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return SafeArea(
|
||||||
|
minimum: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'加入歌单',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
|
||||||
|
title: const Text(
|
||||||
|
'新建歌单',
|
||||||
|
style: TextStyle(color: Color(0xFFB8D4D0)),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _createPlaylistAndAdd(context, repo, songPath);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: Colors.white12),
|
||||||
|
if (playlists.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Text(
|
||||||
|
'还没有歌单,先新建一个',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 320),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: playlists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = playlists[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.queue_music,
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${playlist.songCount} 首',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final added = await repo.addSongs(
|
||||||
|
playlist.id,
|
||||||
|
[songPath],
|
||||||
|
);
|
||||||
|
if (!sheetContext.mounted) return;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(added > 0
|
||||||
|
? '已加入「${playlist.name}」'
|
||||||
|
: '这首歌已经在「${playlist.name}」里了'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
RouteManager().setTemporarilyHidden(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlaylistAndAdd(
|
||||||
|
BuildContext context,
|
||||||
|
PlaylistRepository repo,
|
||||||
|
String songPath,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text('新建歌单', style: TextStyle(color: Colors.white)),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(hintText: '歌单名称'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(dialogContext, controller.text.trim()),
|
||||||
|
child: const Text('创建'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name == null || name.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final playlist = await repo.createPlaylist(name);
|
||||||
|
await repo.addSongs(playlist.id, [songPath]);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('已新建「$name」并加入这首歌')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('新建歌单失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// lib/widgets/full_player_page.dart
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../services/audio_service.dart';
|
|
||||||
|
|
||||||
class FullPlayerPage extends StatelessWidget {
|
|
||||||
const FullPlayerPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final service = context.watch<AudioService>();
|
|
||||||
final song = service.currentSong!;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFF0E1211),
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_ios_new),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.playlist_play_outlined),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 280,
|
|
||||||
height: 280,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: const Color(0xFF2A3332),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF7C9A9E).withOpacity(0.2),
|
|
||||||
blurRadius: 60,
|
|
||||||
spreadRadius: 10,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child:
|
|
||||||
const Icon(Icons.music_note, size: 80, color: Colors.white24),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 60),
|
|
||||||
Text(
|
|
||||||
song.title,
|
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
song.artist,
|
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey[400]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
Slider(
|
|
||||||
value: 0.3,
|
|
||||||
onChanged: (v) {},
|
|
||||||
activeColor: const Color(0xFFB8D4D0),
|
|
||||||
inactiveColor: Colors.grey[800],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.skip_previous, size: 32),
|
|
||||||
color: Colors.white60,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 20),
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 32,
|
|
||||||
backgroundColor: const Color(0xFF7C9A9E),
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
service.isPlaying ? Icons.pause : Icons.play_arrow,
|
|
||||||
color: Colors.black87,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
onPressed: () => context.read<AudioService>().togglePlay(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 20),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.skip_next, size: 32),
|
|
||||||
color: Colors.white60,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.volume_up_outlined),
|
|
||||||
color: Colors.white38,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import 'dart:io';
|
||||||
// lib/widgets/global_mini_player.dart
|
// lib/widgets/global_mini_player.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../utils/navigation.dart'; // ⭐ 导入 navigatorKey
|
||||||
|
|
||||||
class GlobalMiniPlayer extends StatelessWidget {
|
class GlobalMiniPlayer extends StatelessWidget {
|
||||||
const GlobalMiniPlayer({super.key});
|
const GlobalMiniPlayer({super.key});
|
||||||
@@ -45,11 +47,9 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// ⭐ 使用 Navigator.of(context) 替代 navigatorKey
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
Navigator.of(context, rootNavigator: true)
|
navigatorKey.currentState?.pushNamed('/player');
|
||||||
.pushNamed('/player');
|
|
||||||
},
|
},
|
||||||
// ⭐ 替换弃用的 withOpacity 为 withValues
|
|
||||||
highlightColor: Colors.white.withValues(alpha: 0.05),
|
highlightColor: Colors.white.withValues(alpha: 0.05),
|
||||||
splashColor: Colors.white.withValues(alpha: 0.1),
|
splashColor: Colors.white.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -67,9 +67,22 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
image: MemoryImage(song.artwork!),
|
image: MemoryImage(song.artwork!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
|
: (song.artworkPath != null &&
|
||||||
|
song.artworkPath!.isNotEmpty &&
|
||||||
|
File(song.artworkPath!)
|
||||||
|
.existsSync())
|
||||||
|
? DecorationImage(
|
||||||
|
image: FileImage(
|
||||||
|
File(song.artworkPath!)),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: song.artwork == null
|
child: (song.artwork == null &&
|
||||||
|
(song.artworkPath == null ||
|
||||||
|
song.artworkPath!.isEmpty ||
|
||||||
|
!File(song.artworkPath!)
|
||||||
|
.existsSync()))
|
||||||
? const Icon(
|
? const Icon(
|
||||||
Icons.music_note,
|
Icons.music_note,
|
||||||
color: Colors.white38,
|
color: Colors.white38,
|
||||||
@@ -124,8 +137,8 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// ⭐ 使用 Navigator.of(context)
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
Navigator.of(context).pushNamed('/playlist');
|
navigatorKey.currentState?.pushNamed('/playlist');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// lib/widgets/song_artwork.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 歌曲封面:优先进内存字节,其次本地封面文件,最后回退图标。
|
||||||
|
///
|
||||||
|
/// 列表页一次会构建几十行,这里用 `existsSync` 判断一次即可,
|
||||||
|
/// 图片本身由 Flutter 按需解码。
|
||||||
|
class SongArtwork extends StatelessWidget {
|
||||||
|
final Uint8List? bytes;
|
||||||
|
final String? filePath;
|
||||||
|
final double size;
|
||||||
|
final IconData fallbackIcon;
|
||||||
|
final Color? fallbackColor;
|
||||||
|
|
||||||
|
const SongArtwork({
|
||||||
|
super.key,
|
||||||
|
this.bytes,
|
||||||
|
this.filePath,
|
||||||
|
this.size = 44,
|
||||||
|
this.fallbackIcon = Icons.music_note,
|
||||||
|
this.fallbackColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget child;
|
||||||
|
if (bytes != null && bytes!.isNotEmpty) {
|
||||||
|
child = Image.memory(
|
||||||
|
bytes!,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, __, ___) => _fallback(),
|
||||||
|
);
|
||||||
|
} else if (filePath != null &&
|
||||||
|
filePath!.isNotEmpty &&
|
||||||
|
File(filePath!).existsSync()) {
|
||||||
|
child = Image.file(
|
||||||
|
File(filePath!),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, __, ___) => _fallback(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
child = _fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(width: size, height: size, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallback() {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color: const Color(0xFF232928),
|
||||||
|
child: Icon(
|
||||||
|
fallbackIcon,
|
||||||
|
size: size * 0.55,
|
||||||
|
color: fallbackColor ?? const Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-32
@@ -13,10 +13,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.9"
|
version: "4.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -101,18 +101,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.3"
|
||||||
code_assets:
|
code_assets:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -220,10 +220,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.2.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -244,10 +244,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.9.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -268,10 +268,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: jni_flutter
|
name: jni_flutter
|
||||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.3"
|
||||||
jni_util:
|
jni_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -284,10 +284,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -412,10 +412,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mime
|
name: mime
|
||||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -428,10 +428,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.5.0"
|
version: "9.6.0"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -588,18 +588,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pub_semver
|
name: pub_semver
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.1"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_use
|
name: record_use
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "1.1.1"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -628,18 +628,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.27"
|
version: "2.4.28"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_foundation
|
name: shared_preferences_foundation
|
||||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.6"
|
version: "2.5.7"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.2"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -817,10 +817,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.3.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -849,10 +849,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: yaml
|
name: yaml
|
||||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
+2
-3
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.0+1
|
version: 1.1.0-dev.1+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
@@ -32,12 +32,11 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
media_kit: 1.2.6
|
media_kit: 1.2.6
|
||||||
media_kit_libs_audio: ^1.0.7
|
media_kit_libs_audio: ^1.0.7
|
||||||
#media_kit_libs_android_audio: 1.3.8
|
|
||||||
shared_preferences: 2.2.2
|
shared_preferences: 2.2.2
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
dio: ^5.4.0 # HTTP 客户端
|
dio: ^5.4.0 # HTTP 客户端
|
||||||
xml: ^6.5.0
|
xml: ^6.5.0
|
||||||
audio_service: ^0.18.19
|
audio_service: 0.18.19
|
||||||
audio_session: ^0.1.21
|
audio_session: ^0.1.21
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
flutter_cache_manager: ^3.3.1
|
flutter_cache_manager: ^3.3.1
|
||||||
|
|||||||
@@ -684,3 +684,6 @@ await AudioService.start(
|
|||||||
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
|
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
|
||||||
|
|
||||||
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
|
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
|
||||||
|
|
||||||
|
大樱子留:
|
||||||
|
26.10.22-23:22 多实例问题已修复,接下来会将代码合并到main分支,大肥鱼立功一件,有时候harness确实很有意义
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// test/metadata_normalizer_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_model.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_normalizer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final normalizer = MetadataNormalizer();
|
||||||
|
|
||||||
|
RawMetadata raw({
|
||||||
|
String title = '',
|
||||||
|
String artist = '',
|
||||||
|
String album = '',
|
||||||
|
int year = 0,
|
||||||
|
}) =>
|
||||||
|
RawMetadata(title: title, artist: artist, album: album, year: year);
|
||||||
|
|
||||||
|
group('normalize 字段来源', () {
|
||||||
|
test('标签齐全时三个字段都标记为来自标签', () {
|
||||||
|
final n = normalizer.normalize(
|
||||||
|
raw(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦'),
|
||||||
|
fileName: 'track01.mp3',
|
||||||
|
filePath: '/音乐/周杰伦/track01.mp3',
|
||||||
|
);
|
||||||
|
expect(n.title, '夜曲');
|
||||||
|
expect(n.artist, '周杰伦');
|
||||||
|
expect(n.album, '十一月的萧邦');
|
||||||
|
expect(n.titleFromTag, isTrue);
|
||||||
|
expect(n.artistFromTag, isTrue);
|
||||||
|
expect(n.albumFromTag, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有标签时回退到文件名与目录', () {
|
||||||
|
final n = normalizer.normalize(
|
||||||
|
raw(),
|
||||||
|
fileName: '晴天.mp3',
|
||||||
|
filePath: '/音乐/周杰伦/晴天.mp3',
|
||||||
|
);
|
||||||
|
expect(n.title, '晴天');
|
||||||
|
expect(n.artist, '周杰伦');
|
||||||
|
expect(n.titleFromTag, isFalse);
|
||||||
|
expect(n.artistFromTag, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('evaluate 置信度分级', () {
|
||||||
|
NormalizedMetadata norm({
|
||||||
|
required String title,
|
||||||
|
required String artist,
|
||||||
|
String album = '',
|
||||||
|
bool titleTag = true,
|
||||||
|
bool artistTag = true,
|
||||||
|
bool albumTag = false,
|
||||||
|
}) =>
|
||||||
|
NormalizedMetadata(
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
titleFromTag: titleTag,
|
||||||
|
artistFromTag: artistTag,
|
||||||
|
albumFromTag: albumTag,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('标题 + 艺术家 + 专辑 → 0.90', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.90, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有标题和艺术家 → 0.80', () {
|
||||||
|
final c = normalizer.evaluate(norm(title: '夜曲', artist: '周杰伦'), const []);
|
||||||
|
expect(c.confidence, closeTo(0.80, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有文件名(无标签)→ 0.35,低于阈值', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: 'track01', artist: '未知艺术家', titleTag: false, artistTag: false),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.35, 0.001));
|
||||||
|
expect(c.confidence < 0.6, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史一致性加权:同艺人 ≥2 条 → +0.05', () {
|
||||||
|
final history = [
|
||||||
|
{'artist': '周杰伦', 'album': ''},
|
||||||
|
{'artist': '周杰伦', 'album': ''},
|
||||||
|
];
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦'),
|
||||||
|
history,
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.85, 0.001));
|
||||||
|
expect(c.evidence['artist_history'], 0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('证据可解释:每一项加分都有记录', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.evidence.containsKey('base'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('title_from_tag'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('artist_from_tag'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('album'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('decide 阈值', () {
|
||||||
|
test('高于阈值时 source=normalized', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
const NormalizedMetadata(
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
titleFromTag: true,
|
||||||
|
artistFromTag: true,
|
||||||
|
),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
final f = normalizer.decide(c);
|
||||||
|
expect(f.source, 'normalized');
|
||||||
|
expect(f.title, '夜曲');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('低于阈值时 source=pending', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
const NormalizedMetadata(
|
||||||
|
title: 'track01',
|
||||||
|
artist: '未知艺术家',
|
||||||
|
titleFromTag: false,
|
||||||
|
artistFromTag: false,
|
||||||
|
),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
final f = normalizer.decide(c);
|
||||||
|
expect(f.source, 'pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// test/metadata_stability_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_model.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_stability.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
CandidateMetadata candidate({double confidence = 0.8}) => CandidateMetadata(
|
||||||
|
metadata: const NormalizedMetadata(title: '夜曲', artist: '周杰伦'),
|
||||||
|
confidence: confidence,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('MetadataStability', () {
|
||||||
|
test('首次见到 → pending,次数 1', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: null,
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'first_seen');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第二次结果一致且置信度达标 → verified', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
expect(d.reason, 'stable');
|
||||||
|
expect(d.validationCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题或艺术家冲突 → 次数重置为 1,保持 pending', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.9,
|
||||||
|
'validation_count': 5,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '完全不同的歌',
|
||||||
|
artist: '别的歌手',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'conflict');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('置信度不足 → pending,但次数继续累计', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.4,
|
||||||
|
'validation_count': 3,
|
||||||
|
},
|
||||||
|
candidate: candidate(confidence: 0.5),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'low_confidence');
|
||||||
|
expect(d.validationCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一致但只验证过一次 → pending', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 0,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'need_second_validation');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字段缺失不算冲突(旧记录没有艺术家)', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '',
|
||||||
|
'confidence': 0.7,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
expect(d.validationCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大小写与首尾空格不影响一致性判断', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': ' Night Song ',
|
||||||
|
'artist': 'JAY',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: 'night song',
|
||||||
|
artist: 'jay',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// test/playlist_summary_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/models/playlist_summary.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PlaylistSummary.fromRow', () {
|
||||||
|
test('完整行解析', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'abc',
|
||||||
|
'name': '随手',
|
||||||
|
'song_count': 12,
|
||||||
|
'cover_path': '/data/artworks/x.jpg',
|
||||||
|
'updated_at': 1700000000,
|
||||||
|
});
|
||||||
|
expect(s.id, 'abc');
|
||||||
|
expect(s.name, '随手');
|
||||||
|
expect(s.songCount, 12);
|
||||||
|
expect(s.coverPath, '/data/artworks/x.jpg');
|
||||||
|
expect(s.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有封面时 coverPath 为 null', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'abc',
|
||||||
|
'name': '车用',
|
||||||
|
'song_count': 3,
|
||||||
|
'cover_path': '',
|
||||||
|
});
|
||||||
|
expect(s.coverPath, isNull);
|
||||||
|
expect(s.songCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空歌单 isEmpty', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'x',
|
||||||
|
'name': '空',
|
||||||
|
'song_count': 0,
|
||||||
|
});
|
||||||
|
expect(s.isEmpty, isTrue);
|
||||||
|
expect(s.coverPath, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字段缺失时不崩', () {
|
||||||
|
final s = PlaylistSummary.fromRow({});
|
||||||
|
expect(s.id, '');
|
||||||
|
expect(s.name, '');
|
||||||
|
expect(s.songCount, 0);
|
||||||
|
expect(s.isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// test/position_guard_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/utils/position_guard.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldIgnorePositionRegression', () {
|
||||||
|
test('正常播放推进:不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 78),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('77s 突然归零:拦截(这就是"从头重播"的元凶)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户切歌:允许回退', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: true,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自动恢复中:允许回退(我们自己重开的)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刚开始播放(最后位置 <10s):不拦截,避免误伤起播', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 8),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('小幅回退(77s -> 73s):不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 73),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// test/song_key_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/utils/song_key.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('songKeyFor', () {
|
||||||
|
test('同一 URL 多次计算结果一致(跨进程稳定)', () {
|
||||||
|
const url = 'https://example.com/dav/music/a.flac';
|
||||||
|
expect(songKeyFor(url), songKeyFor(url));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('与探测大小无关:不同阶段的头部请求得到同一个键', () {
|
||||||
|
// 键只依赖 URL,因此 128KB / 512KB 两次探测不会产生两个缓存键
|
||||||
|
const url = 'https://example.com/dav/music/b.flac';
|
||||||
|
final key = songKeyFor(url);
|
||||||
|
expect(key, songKeyForUrl(url));
|
||||||
|
expect(key.length, 32); // md5 十六进制
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不同 URL 得到不同键', () {
|
||||||
|
expect(songKeyFor('https://x/a.flac'), isNot(songKeyFor('https://x/b.flac')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
清听真机自检:安装最新 debug APK,冷启动,抓 logcat,校验 libmpv 是否只有单实例。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
判定依据:
|
||||||
|
- 冷启动后 "PlaybackService 单例创建" 只出现一次;
|
||||||
|
- 日志里出现过的 serviceId 只有 1 个;
|
||||||
|
- media_kit Player 的 hashCode 只有 1 个。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -ExecutionPolicy Bypass -File tools\device-check.ps1
|
||||||
|
powershell -ExecutionPolicy Bypass -File tools\device-check.ps1 -DeviceId 192.168.3.2:36569
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$DeviceId = '',
|
||||||
|
[string]$Apk = '',
|
||||||
|
[int]$WaitSeconds = 12,
|
||||||
|
# 默认校验 debug 包(_dev 后缀),与 release 包并存安装
|
||||||
|
[string]$Package = 'com.lxh.qingting_player.dev'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue' # adb 会往 stderr 写正常提示,用 Stop 会误判
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
if (-not $Apk) { $Apk = Join-Path $root 'build\app\outputs\flutter-apk\app-debug.apk' }
|
||||||
|
$pkg = $Package
|
||||||
|
|
||||||
|
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
|
||||||
|
function Write-Bad($msg) { Write-Host " !! $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
if (-not (Get-Command adb -ErrorAction SilentlyContinue)) {
|
||||||
|
throw '未找到 adb,请把 platform-tools 加入 PATH'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $Apk)) {
|
||||||
|
throw "APK 不存在:$Apk(先执行 gradlew assembleDebug)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 选择设备 ----------
|
||||||
|
# 同一台手机可能同时存在多条 adb 通道(如无线调试 + mDNS),按 serialno 去重
|
||||||
|
$devices = @(adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\sdevice$' } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||||
|
if ($DeviceId) {
|
||||||
|
if ($devices -notcontains $DeviceId) { throw "设备 $DeviceId 不在线,当前:$($devices -join ', ')" }
|
||||||
|
} elseif ($devices.Count -eq 0) {
|
||||||
|
throw '没有在线设备,请先连接手机并授权 USB/无线调试'
|
||||||
|
} elseif ($devices.Count -eq 1) {
|
||||||
|
$DeviceId = $devices[0]
|
||||||
|
} else {
|
||||||
|
$bySerial = @{}
|
||||||
|
foreach ($entry in $devices) {
|
||||||
|
$serial = (& adb -s $entry shell getprop ro.serialno 2>$null | Out-String).Trim()
|
||||||
|
if ($serial) { $bySerial[$serial] = $entry }
|
||||||
|
}
|
||||||
|
if ($bySerial.Count -eq 1) {
|
||||||
|
$DeviceId = @($bySerial.Values)[0]
|
||||||
|
Write-Host " (同一设备的多条 adb 通道,使用 $DeviceId)" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
throw "检测到多台设备,请用 -DeviceId 指定:$($devices -join ', ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$adb = { param($args) & adb -s $DeviceId @args }
|
||||||
|
$model = (& adb -s $DeviceId shell getprop ro.product.model).Trim()
|
||||||
|
$android = (& adb -s $DeviceId shell getprop ro.build.version.release).Trim()
|
||||||
|
Write-Step "设备 $model (Android $android) [$DeviceId]"
|
||||||
|
|
||||||
|
# ---------- 安装 ----------
|
||||||
|
Write-Step "安装 $([IO.Path]::GetFileName($Apk))"
|
||||||
|
$install = & adb -s $DeviceId install -r $Apk 2>&1
|
||||||
|
if (($install -join "`n") -notmatch 'Success') { Write-Bad ($install -join "`n"); exit 1 }
|
||||||
|
Write-Ok '安装成功'
|
||||||
|
|
||||||
|
# ---------- 冷启动 ----------
|
||||||
|
Write-Step '冷启动并抓取日志'
|
||||||
|
$logPath = Join-Path $root 'build\device-check-last.log'
|
||||||
|
$creates = 0
|
||||||
|
# Flyme 有时不会立刻杀掉带前台服务的进程,导致抓不到启动日志:确认进程消失 + 最多重试两次
|
||||||
|
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||||
|
& adb -s $DeviceId shell am force-stop $pkg | Out-Null
|
||||||
|
$deadline = (Get-Date).AddSeconds(10)
|
||||||
|
while ((& adb -s $DeviceId shell pidof $pkg) -and (Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
# 进程还在说明被系统冻结/保活,直接等到超时也无意义,先给它一点时间解冻
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
& adb -s $DeviceId logcat -c
|
||||||
|
# 解析真正的启动组件再启动:applicationId 带 .dev 后缀时,
|
||||||
|
# "$pkg/.MainActivity" 会把 .MainActivity 拼到 .dev 上导致找不到类;
|
||||||
|
# 而 "am start -p $pkg" 在部分 ROM 上又解析不到,所以直接问 PackageManager。
|
||||||
|
$component = (& adb -s $DeviceId shell cmd package resolve-activity --brief $pkg 2>&1 | Select-Object -Last 1).Trim()
|
||||||
|
& adb -s $DeviceId shell am start -W -n $component 2>&1 | Out-Null
|
||||||
|
# 第一次按参数等待;第二次重试时多等一会儿(Flyme 解冻可能很慢)
|
||||||
|
$waitThisTime = if ($attempt -eq 1) { $WaitSeconds } else { [Math]::Max($WaitSeconds, 20) }
|
||||||
|
Start-Sleep -Seconds $waitThisTime
|
||||||
|
& adb -s $DeviceId logcat -d -v time > $logPath
|
||||||
|
# 注意:PS 5.1 的 > 重定向写出的是 UTF-16LE(带 BOM),读取时不要强制 utf8
|
||||||
|
$log = Get-Content $logPath
|
||||||
|
$text = $log -join "`n"
|
||||||
|
$creates = ([regex]::Matches($text, '\[PlaybackService\] 单例创建')).Count
|
||||||
|
if ($creates -ge 1) { break }
|
||||||
|
Write-Host " (第 $attempt 次没抓到冷启动日志,进程可能没退干净,重试)" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 判定 ----------
|
||||||
|
Write-Step '实例检查'
|
||||||
|
$serviceIds = @([regex]::Matches($text, 'serviceId=(\d+)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||||
|
$players = @([regex]::Matches($text, 'Player 已创建.*?player=(\d+)') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||||
|
|
||||||
|
$failed = $false
|
||||||
|
if ($creates -eq 1) { Write-Ok "PlaybackService 单例创建 x$creates" } else { Write-Bad "PlaybackService 单例创建 x$creates(应为 1)"; $failed = $true }
|
||||||
|
if ($serviceIds.Count -eq 1) { Write-Ok "serviceId 唯一:$($serviceIds[0])" } else { Write-Bad "出现 $($serviceIds.Count) 个 serviceId:$($serviceIds -join ', ')"; $failed = $true }
|
||||||
|
if ($players.Count -eq 1) { Write-Ok "Player 唯一:$($players[0])" } else { Write-Bad "出现 $($players.Count) 个 Player:$($players -join ', ')"; $failed = $true }
|
||||||
|
|
||||||
|
# ---------- 系统侧 ----------
|
||||||
|
Write-Step '系统 MediaSession'
|
||||||
|
$sessionIds = @(& adb -s $DeviceId shell dumpsys media_session 2>&1 |
|
||||||
|
Select-String ("media-session " + [regex]::Escape($pkg) + "/media-session/(\d+)") |
|
||||||
|
ForEach-Object { $_.Matches[0].Groups[1].Value } | Sort-Object -Unique)
|
||||||
|
if ($sessionIds.Count -eq 1) { Write-Ok "MediaSession 唯一:$($sessionIds[0])" }
|
||||||
|
elseif ($sessionIds.Count -eq 0) { Write-Host ' -- 还没有 MediaSession(未开始播放,属正常)' -ForegroundColor DarkGray }
|
||||||
|
else { Write-Bad "出现 $($sessionIds.Count) 个 MediaSession:$($sessionIds -join ', ')"; $failed = $true }
|
||||||
|
|
||||||
|
# ---------- 崩溃 / 加载失败 ----------
|
||||||
|
Write-Step '崩溃与原生库'
|
||||||
|
$bad = $log | Select-String 'FATAL EXCEPTION|UnsatisfiedLinkError|Cannot load MediaKitAndroidHelper|Could not load'
|
||||||
|
if ($bad) { $bad | Select-Object -First 5 | ForEach-Object { Write-Bad $_.Line.Trim() }; $failed = $true } else { Write-Ok '无崩溃、无 so 加载失败' }
|
||||||
|
|
||||||
|
# ---------- 结果 ----------
|
||||||
|
Write-Host ''
|
||||||
|
if ($failed) {
|
||||||
|
Write-Host 'RESULT: FAIL —— 仍存在多实例或异常,完整日志:' -ForegroundColor Red
|
||||||
|
Write-Host " $logPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host 'RESULT: PASS —— 单引擎单实例' -ForegroundColor Green
|
||||||
|
Write-Host "日志:$logPath"
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '还需要你手动确认(脚本无法代替耳朵和眼睛):' -ForegroundColor Yellow
|
||||||
|
Write-Host ' 1. 放一首歌,确认只有一路声音(没有重影/叠音)'
|
||||||
|
Write-Host ' 2. 按通知栏暂停/播放,确认播放页状态同步变化'
|
||||||
|
Write-Host ' 3. 拖通知栏进度条,确认播放页进度跟着走'
|
||||||
Reference in New Issue
Block a user