Compare commits
64
Commits
40d4af322b
...
main
| 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 |
+17
-1
@@ -34,4 +34,20 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,4 +98,4 @@ kotlin {
|
|||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
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,6 +1,6 @@
|
|||||||
<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" />
|
||||||
@@ -12,11 +12,9 @@
|
|||||||
<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"
|
||||||
@@ -34,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"
|
||||||
@@ -45,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">
|
||||||
@@ -55,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"
|
||||||
@@ -70,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>
|
|
||||||
|
|||||||
@@ -4,38 +4,26 @@ 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.content.Context
|
||||||
import android.media.AudioManager
|
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()
|
requestAudioFocus()
|
||||||
|
|
||||||
setHighRefreshRate()
|
setHighRefreshRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +51,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
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.3.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+3
-25
@@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
|
|
||||||
import 'package:flutter/services.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';
|
||||||
@@ -107,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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 导航观察者(监听路由变化)
|
// 导航观察者(监听路由变化)
|
||||||
@@ -399,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),
|
||||||
@@ -436,4 +414,4 @@ 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;
|
||||||
|
}
|
||||||
+426
-168
@@ -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,127 +942,42 @@ 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),
|
||||||
|
children: [
|
||||||
|
_buildFavoritesTab(),
|
||||||
|
_buildPlaylistsTab(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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: [
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+531
-200
@@ -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,75 +253,318 @@ 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,
|
/// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
|
||||||
spreadRadius: 10,
|
bool get _isCompact {
|
||||||
),
|
final size = MediaQuery.of(context).size;
|
||||||
],
|
if (size.width <= 0) return false;
|
||||||
image: song.artwork != null
|
return size.height / size.width < 2.05;
|
||||||
? DecorationImage(
|
}
|
||||||
image: MemoryImage(song.artwork!),
|
|
||||||
fit: BoxFit.cover,
|
/// 顶部指示器:当前页对应"长条",另外的页是小点。
|
||||||
)
|
/// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
|
||||||
: null,
|
Widget _buildPageIndicator(bool compact) {
|
||||||
),
|
Widget segment(int index) {
|
||||||
child: song.artwork == null
|
final active = _pageIndex == index;
|
||||||
? const Icon(Icons.music_note,
|
return GestureDetector(
|
||||||
size: 64, color: Colors.white24)
|
onTap: () => _pageController.animateToPage(
|
||||||
: null,
|
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),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
),
|
||||||
Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
);
|
||||||
children: [
|
}
|
||||||
Text(
|
|
||||||
|
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,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
children: [
|
||||||
|
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: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
song.artist,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
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(
|
||||||
|
song.artist,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: compact ? 14 : 16,
|
||||||
|
color: Colors.grey[300],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
maxLines: 1,
|
||||||
_buildProgressSection(service),
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(height: 32),
|
),
|
||||||
_buildControlButtons(service, isPlaying),
|
|
||||||
const SizedBox(height: 16),
|
// ---- 信息区(专辑 / 目录)----
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// ---- 进度 ----
|
||||||
|
_buildProgressSection(service),
|
||||||
|
SizedBox(height: compact ? 10 : 18),
|
||||||
|
|
||||||
|
// ---- 控制 ----
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildProgressSection(AudioService service) {
|
Widget _buildProgressSection(AudioService service) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: service.positionNotifier,
|
valueListenable: service.positionNotifier,
|
||||||
@@ -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,115 +582,118 @@ 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 /
|
||||||
|
duration.inMilliseconds)
|
||||||
final bufferProgress = duration.inMilliseconds > 0
|
.clamp(0.0, 1.0);
|
||||||
? (service.bufferedNotifier.value.inMilliseconds /
|
|
||||||
duration.inMilliseconds)
|
|
||||||
.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(
|
||||||
child: LayoutBuilder(
|
_dragProgress != null
|
||||||
builder: (context, constraints) {
|
? Duration(
|
||||||
final width = constraints.maxWidth;
|
milliseconds:
|
||||||
final bufferWidth =
|
(_dragProgress! * duration.inMilliseconds)
|
||||||
width * bufferProgress.clamp(0.0, 1.0);
|
.round(),
|
||||||
final progressWidth =
|
)
|
||||||
width * displayProgress.clamp(0.0, 1.0);
|
: position,
|
||||||
|
),
|
||||||
return Stack(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
alignment: Alignment.centerLeft,
|
),
|
||||||
children: [
|
const SizedBox(width: 10),
|
||||||
Container(
|
Expanded(
|
||||||
width: width,
|
child: SizedBox(
|
||||||
height: 4,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
child: LayoutBuilder(
|
||||||
color: Colors.grey[800],
|
builder: (context, constraints) {
|
||||||
borderRadius: BorderRadius.circular(2),
|
final width = constraints.maxWidth;
|
||||||
),
|
return Stack(
|
||||||
),
|
alignment: Alignment.centerLeft,
|
||||||
Container(
|
children: [
|
||||||
width: bufferWidth,
|
// 背景轨道
|
||||||
height: 4,
|
Positioned(
|
||||||
decoration: BoxDecoration(
|
left: 0,
|
||||||
color: const Color(0xFF4A7A7A),
|
right: 0,
|
||||||
borderRadius: BorderRadius.circular(2),
|
top: 10.5,
|
||||||
),
|
child: Container(
|
||||||
),
|
height: 3,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
width: progressWidth,
|
color: Colors.grey[800],
|
||||||
height: 4,
|
borderRadius: BorderRadius.circular(2),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFB8D4D0),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: SliderTheme(
|
|
||||||
data: SliderTheme.of(context).copyWith(
|
|
||||||
trackHeight: 0,
|
|
||||||
activeTrackColor: Colors.transparent,
|
|
||||||
inactiveTrackColor: Colors.transparent,
|
|
||||||
thumbColor: Colors.transparent,
|
|
||||||
overlayColor: Colors.transparent,
|
|
||||||
thumbShape: const RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: 0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Slider(
|
),
|
||||||
value: displayProgress.clamp(0.0, 1.0),
|
// 已缓冲
|
||||||
min: 0.0,
|
Positioned(
|
||||||
max: 1.0,
|
left: 0,
|
||||||
onChanged: (value) {
|
top: 10.5,
|
||||||
setState(() {
|
child: Container(
|
||||||
_dragProgress = value;
|
width: width * bufferProgress.clamp(0.0, 1.0),
|
||||||
});
|
height: 3,
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
onChangeEnd: (value) {
|
color: const Color(0xFF4A7A7A),
|
||||||
final newPosition = Duration(
|
borderRadius: BorderRadius.circular(2),
|
||||||
milliseconds:
|
),
|
||||||
(value * duration.inMilliseconds)
|
|
||||||
.round(),
|
|
||||||
);
|
|
||||||
service.seekTo(newPosition);
|
|
||||||
setState(() {
|
|
||||||
_dragProgress = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 已播放
|
||||||
],
|
Positioned(
|
||||||
);
|
left: 0,
|
||||||
},
|
top: 10.5,
|
||||||
|
child: Container(
|
||||||
|
width:
|
||||||
|
width * displayProgress.clamp(0.0, 1.0),
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 0,
|
||||||
|
activeTrackColor: Colors.transparent,
|
||||||
|
inactiveTrackColor: Colors.transparent,
|
||||||
|
thumbColor: Colors.transparent,
|
||||||
|
overlayColor: Colors.transparent,
|
||||||
|
thumbShape: const RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: displayProgress.clamp(0.0, 1.0),
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _dragProgress = value);
|
||||||
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
final newPosition = Duration(
|
||||||
|
milliseconds:
|
||||||
|
(value * duration.inMilliseconds)
|
||||||
|
.round(),
|
||||||
|
);
|
||||||
|
service.seekTo(newPosition);
|
||||||
|
setState(() => _dragProgress = null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(width: 10),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Text(
|
||||||
children: [
|
_formatDuration(duration),
|
||||||
Text(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
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(
|
||||||
isCurrent ? Icons.play_arrow : Icons.music_note,
|
bytes: song.artwork,
|
||||||
color:
|
filePath: _artworkByPath[song.id],
|
||||||
isCurrent ? const Color(0xFFB8D4D0) : Colors.grey[600],
|
size: 48,
|
||||||
size: 24,
|
fallbackIcon:
|
||||||
|
isCurrent ? Icons.play_arrow : Icons.music_note,
|
||||||
|
fallbackColor: isCurrent
|
||||||
|
? const Color(0xFFB8D4D0)
|
||||||
|
: 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+356
-48
@@ -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),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消'),
|
||||||
),
|
),
|
||||||
_buildTile(
|
TextButton(
|
||||||
icon: Icons.cloud_outlined,
|
onPressed: () => Navigator.pop(context, true),
|
||||||
title: 'WebDAV 云盘',
|
child: const Text('清理', style: TextStyle(color: Colors.redAccent)),
|
||||||
subtitle: '连接你的 NAS 或网盘',
|
|
||||||
onTap: () {
|
|
||||||
// 🟢 这里调用你现成的 WebDAV 通道
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('待接入 WebDAV 逻辑')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_buildTile(
|
|
||||||
icon: Icons.equalizer_outlined,
|
|
||||||
title: '均衡器',
|
|
||||||
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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testConnection() async {
|
||||||
|
try {
|
||||||
|
await WebDAVService.instance.listDirectory(path: '/');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('连接正常')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTile(
|
|
||||||
{required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required VoidCallback onTap}) {
|
|
||||||
return ListTile(
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -116,9 +116,13 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final controls = _buildControls(playing);
|
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(
|
final state = audio_service.PlaybackState(
|
||||||
controls: controls,
|
controls: controls,
|
||||||
processingState: duration.inMilliseconds > 0
|
processingState: prepared
|
||||||
? audio_service.AudioProcessingState.ready
|
? audio_service.AudioProcessingState.ready
|
||||||
: audio_service.AudioProcessingState.idle,
|
: audio_service.AudioProcessingState.idle,
|
||||||
playing: playing,
|
playing: playing,
|
||||||
@@ -242,7 +246,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
debugPrint('▶️ [handler] play() called');
|
debugPrint('▶️ [handler] play() called');
|
||||||
await _playback.resume();
|
// 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
|
||||||
|
await AudioService().playOrResume();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+613
-80
@@ -7,9 +7,13 @@ 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';
|
||||||
@@ -28,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() => {
|
||||||
@@ -80,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;
|
||||||
|
|
||||||
// ---- 待恢复的播放进度 ----
|
// ---- 待恢复的播放进度 ----
|
||||||
@@ -216,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) {
|
||||||
@@ -252,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++;
|
||||||
@@ -282,9 +390,34 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _playWithHeaders(song.url!);
|
try {
|
||||||
|
_trackStartAt = DateTime.now();
|
||||||
|
_playReadyAt = null;
|
||||||
|
_firstAudioReported = false;
|
||||||
|
_metadataNeedsRetry = false;
|
||||||
|
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) {
|
||||||
@@ -298,17 +431,332 @@ 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 {
|
||||||
_isChangingTrack = false;
|
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
|
||||||
|
if (epoch == _switchEpoch) {
|
||||||
|
_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;
|
||||||
|
|
||||||
@@ -351,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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -405,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;
|
||||||
@@ -442,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();
|
||||||
@@ -461,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();
|
||||||
@@ -494,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;
|
||||||
@@ -516,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;
|
||||||
@@ -550,10 +998,33 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,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;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -665,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((_) {
|
||||||
@@ -838,8 +1391,13 @@ class AudioService extends ChangeNotifier {
|
|||||||
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) {
|
||||||
@@ -848,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,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,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -66,9 +67,22 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
image: MemoryImage(song.artwork!),
|
image: MemoryImage(song.artwork!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: (song.artworkPath != null &&
|
||||||
|
song.artworkPath!.isNotEmpty &&
|
||||||
|
File(song.artworkPath!)
|
||||||
|
.existsSync())
|
||||||
|
? DecorationImage(
|
||||||
|
image: FileImage(
|
||||||
|
File(song.artworkPath!)),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: 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,
|
||||||
|
|||||||
@@ -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
-2
@@ -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
|
||||||
@@ -36,7 +36,7 @@ dependencies:
|
|||||||
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
|
||||||
|
|||||||
+4
-1
@@ -683,4 +683,7 @@ 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