Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672a545c38 | ||
|
|
d3ed871626 | ||
|
|
c1e720311e | ||
|
|
4a8b1f28db | ||
|
|
d07086aaa0 | ||
|
|
0a1ebfa5df | ||
|
|
18308d9d4e | ||
|
|
b8138ad125 | ||
|
|
9f53d1c604 | ||
|
|
98db2321fb | ||
|
|
ff41769c56 | ||
|
|
30111a5f0c | ||
|
|
a5849969d3 | ||
|
|
af895c18e1 | ||
|
|
2d571eaae4 | ||
|
|
111369c20c | ||
|
|
b517c75cdd | ||
|
|
937bcdd63d | ||
|
|
2b33c17aab | ||
|
|
9762569832 | ||
|
|
9e58d52678 | ||
|
|
eab4a12ccc | ||
|
|
46a9add5cb | ||
|
|
87011c7adc | ||
|
|
6445ee94bf | ||
|
|
6f9fe6262d | ||
|
|
2252b72a14 | ||
|
|
083a1612d5 | ||
|
|
b62af9648b | ||
|
|
56c26e2fd5 | ||
|
|
40d4af322b | ||
|
|
1aba004e43 | ||
|
|
cdf77b308d | ||
|
|
200067a3b2 |
+16
@@ -35,3 +35,19 @@ desktop.ini
|
|||||||
/tmp/
|
/tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
|
|
||||||
|
# ---------- 本地构建 / IDE 产物 ----------
|
||||||
|
gradle-build.log
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# ---------- 个人笔记(不上传) ----------
|
||||||
|
bug-todo.txt
|
||||||
|
|
||||||
|
# ---------- 服务器/监控日志(本地参考,不上传) ----------
|
||||||
|
monitor-log-*.csv
|
||||||
|
|
||||||
|
# ---------- 签名材料(绝不入库) ----------
|
||||||
|
android/key.properties
|
||||||
|
android/keystore/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
|
revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,26 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||||
- platform: ios
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: linux
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: macos
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: web
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
- platform: windows
|
|
||||||
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -8,11 +8,10 @@ GeneratedPluginRegistrant.java
|
|||||||
.cxx/
|
.cxx/
|
||||||
.kotlin
|
.kotlin
|
||||||
/build
|
/build
|
||||||
|
.settings
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
.settings
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正式签名:android/key.properties(不入库)
|
||||||
|
// 文件缺失时自动回退到 debug keystore,保证 clone 下来也能直接构建
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.lxh.qingting_player"
|
namespace = "com.lxh.qingting_player"
|
||||||
compileSdk = 36
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -14,49 +27,65 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.lxh.qingting_player"
|
applicationId = "com.lxh.qingting_player"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||||
|
// flag during build.
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
// 仅打包 arm64-v8a
|
// 与 main 分支保持一致:仅打包 arm64-v8a
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters.addAll(listOf("arm64-v8a"))
|
abiFilters.addAll(listOf("arm64-v8a"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式指定 jniLibs 源目录
|
// AGP 9 不再允许在 AndroidManifest 里写 android:extractNativeLibs,
|
||||||
sourceSets {
|
// 改为在这里声明,效果等价于原先的“强制提取 .so”
|
||||||
getByName("main") {
|
packaging {
|
||||||
jniLibs.srcDirs("src/main/jniLibs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Copy> {
|
|
||||||
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
|
|
||||||
from("src/main/jniLibs")
|
|
||||||
into("$buildDir/intermediates/merged_jni_libs/debug/out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 打包配置
|
|
||||||
packagingOptions {
|
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging = true
|
useLegacyPackaging = true
|
||||||
}
|
}
|
||||||
pickFirsts += "**/*.so"
|
}
|
||||||
|
|
||||||
|
// AGP 9 默认关闭 resValue 生成,这里显式打开(debug 需要 "清听 Dev" 的应用名)
|
||||||
|
buildFeatures {
|
||||||
|
resValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
|
// 测试包与正式包并存安装:com.lxh.qingting_player.dev
|
||||||
|
// 说明:Android 包名每一段必须以字母开头,所以没法直接写 "_dev",
|
||||||
|
// 这里用 ".dev" 后缀,等价于给 debug 打上 dev 标记。
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
resValue("string", "app_name", "清听 Dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
resValue("string", "app_name", "清听")
|
||||||
proguardFiles(
|
// 有 key.properties 就用正式签名,否则回退 debug(仅本机调试用)
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
"proguard-rules.pro"
|
signingConfigs.getByName("release")
|
||||||
)
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-45
@@ -1,45 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# media_kit 核心
|
|
||||||
# ============================================================
|
|
||||||
-keep class com.media_kit.** { *; }
|
|
||||||
-keep class com.alexmercerind.** { *; }
|
|
||||||
-keep class org.mozilla.** { *; }
|
|
||||||
|
|
||||||
# 保留所有 native 方法
|
|
||||||
-keepclasseswithmembernames class * {
|
|
||||||
native <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 保留所有 JNI 相关
|
|
||||||
-keep class * implements java.lang.reflect.Method { *; }
|
|
||||||
-keep class * extends java.lang.reflect.Method { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# audio_service 插件
|
|
||||||
# ============================================================
|
|
||||||
-keep class com.ryanheise.audioservice.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Flutter 核心
|
|
||||||
# ============================================================
|
|
||||||
-keep class io.flutter.** { *; }
|
|
||||||
-keep class io.flutter.plugin.** { *; }
|
|
||||||
-keep class io.flutter.embedding.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 忽略 Google Play Core 缺失类(如果不想添加依赖)
|
|
||||||
# ============================================================
|
|
||||||
-dontwarn com.google.android.play.core.**
|
|
||||||
-keep class com.google.android.play.core.** { *; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 保留 Serializable 支持
|
|
||||||
# ============================================================
|
|
||||||
-keepclassmembers class * implements java.io.Serializable {
|
|
||||||
static final long serialVersionUID;
|
|
||||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
|
||||||
private void writeObject(java.io.ObjectOutputStream);
|
|
||||||
private void readObject(java.io.ObjectInputStream);
|
|
||||||
java.lang.Object writeReplace();
|
|
||||||
java.lang.Object readResolve();
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.lxh.qingting_player">
|
<!-- package 由 build.gradle.kts 的 namespace / applicationId 决定;AGP 9 已忽略这里的 package 属性 -->
|
||||||
|
<!-- 权限 -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="清听"
|
android:label="@string/app_name"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback">
|
||||||
android:extractNativeLibs="true"> <!-- ⭐ 强制提取 .so,避免压缩后加载失败 -->
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- ⭐ AudioService(不加 meta-data) -->
|
||||||
<!-- AudioService -->
|
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -44,8 +42,9 @@
|
|||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
<!-- MediaButtonReceiver -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<!-- ⭐ MediaButtonReceiver -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- FileProvider for artwork(已配置,保持不变) -->
|
<!-- ⭐ FileProvider -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
@@ -69,12 +68,15 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
-20
@@ -4,41 +4,54 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
// 必须继承 AudioServiceActivity,而不是 FlutterActivity:
|
||||||
|
// audio_service 0.18.x 会把 Dart 入口跑在一个单独的缓存引擎(audio_service_engine)里。
|
||||||
|
// 若这里用 FlutterActivity,Activity 会自建第二个 FlutterEngine,
|
||||||
|
// main() 因此被跑两遍,产生两个 PlaybackService / 两个 media_kit Player(libmpv 多实例),
|
||||||
|
// 表现为两路声音、通知栏控制的是另一个实例。
|
||||||
|
// AudioServiceActivity 通过 provideFlutterEngine() 复用同一个缓存引擎,保证全局只有一个 Player。
|
||||||
|
class MainActivity : AudioServiceActivity() {
|
||||||
|
|
||||||
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// ⭐ 强制加载 libmediakitandroidhelper.so,触发 JNI_OnLoad,缓存 JavaVM
|
|
||||||
try {
|
|
||||||
System.loadLibrary("mediakitandroidhelper")
|
|
||||||
Log.d("MainActivity", "✅ libmediakitandroidhelper loaded")
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
Log.e("MainActivity", "❌ libmediakitandroidhelper load failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ⭐ 可选:预加载 libmpv.so(让系统 linker 也可见,增强兼容性)
|
|
||||||
try {
|
|
||||||
System.loadLibrary("mpv")
|
|
||||||
Log.d("MainActivity", "✅ libmpv loaded")
|
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
|
||||||
Log.e("MainActivity", "❌ libmpv load failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
requestAudioFocus()
|
||||||
setHighRefreshRate()
|
setHighRefreshRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||||
|
Log.d("MainActivity", "音频焦点变化: $focusChange")
|
||||||
|
},
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
null,
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d("MainActivity", "✅ 已请求音频焦点")
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
// FileProvider MethodChannel for sharing artwork via content:// URI
|
// FileProvider MethodChannel
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "getContentUri") {
|
if (call.method == "getContentUri") {
|
||||||
@@ -3,6 +3,7 @@ android.useAndroidX=true
|
|||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
# This builtInKotlin flag was added by the Flutter template
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# 本机 Kotlin 守护进程连不上(fallback 编译),且增量缓存易损坏,沿用 main 分支原有的设置
|
||||||
kotlin.incremental=false
|
kotlin.incremental=false
|
||||||
kotlin.parallel.tasks.in.project=false
|
kotlin.parallel.tasks.in.project=false
|
||||||
android.builtInKotlin=false
|
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-all.zip
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-all.zip
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="FacetManager">
|
|
||||||
<facet type="android" name="Android">
|
|
||||||
<configuration>
|
|
||||||
<option name="ALLOW_USER_CONFIGURATION" value="false" />
|
|
||||||
<option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/gen" />
|
|
||||||
<option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/gen" />
|
|
||||||
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/app/src/main/AndroidManifest.xml" />
|
|
||||||
<option name="RES_FOLDER_RELATIVE_PATH" value="/app/src/main/res" />
|
|
||||||
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/app/src/main/assets" />
|
|
||||||
<option name="LIBS_FOLDER_RELATIVE_PATH" value="/app/src/main/libs" />
|
|
||||||
<option name="PROGUARD_LOGS_FOLDER_RELATIVE_PATH" value="/app/src/main/proguard_logs" />
|
|
||||||
</configuration>
|
|
||||||
</facet>
|
|
||||||
</component>
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/app/src/main/java" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/app/src/main/kotlin" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Android API 24 Platform" jdkType="Android SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
<orderEntry type="library" name="Flutter for Android" level="project" />
|
|
||||||
<orderEntry type="library" name="KotlinJavaRuntime" level="project" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.0.1" apply false
|
id("com.android.application") version "9.1.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// lib/audio/player_controller.dart
|
|
||||||
import '../services/playback_service.dart'; // ⭐ 修正路径
|
|
||||||
|
|
||||||
class PlayerController {
|
|
||||||
static final PlayerController _instance = PlayerController._internal();
|
|
||||||
factory PlayerController() => _instance;
|
|
||||||
PlayerController._internal();
|
|
||||||
|
|
||||||
final PlaybackService _playback = PlaybackService();
|
|
||||||
|
|
||||||
// ---- 状态流(只读) ----
|
|
||||||
Stream<bool> get playingStream => _playback.player.stream.playing;
|
|
||||||
Stream<Duration> get positionStream => _playback.player.stream.position;
|
|
||||||
Stream<Duration> get durationStream => _playback.player.stream.duration;
|
|
||||||
Stream<void> get completedStream => _playback.player.stream.completed;
|
|
||||||
|
|
||||||
// ---- 当前状态快照 ----
|
|
||||||
bool get isPlaying => _playback.player.state.playing;
|
|
||||||
Duration get position => _playback.player.state.position;
|
|
||||||
Duration get duration => _playback.player.state.duration;
|
|
||||||
|
|
||||||
// ---- 控制命令 ----
|
|
||||||
Future<void> play() => _playback.resume();
|
|
||||||
Future<void> pause() => _playback.pause();
|
|
||||||
Future<void> stop() => _playback.stop();
|
|
||||||
Future<void> seek(Duration position) => _playback.seek(position);
|
|
||||||
|
|
||||||
// ---- 加载歌曲 ----
|
|
||||||
void load(String url, {Map<String, String>? headers}) {
|
|
||||||
_playback.play(url, headers: headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
+69
-57
@@ -5,6 +5,7 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'services/audio_service.dart';
|
import 'services/audio_service.dart';
|
||||||
import 'services/playback_service.dart';
|
import 'services/playback_service.dart';
|
||||||
import 'services/app_lifecycle_service.dart';
|
import 'services/app_lifecycle_service.dart';
|
||||||
@@ -15,8 +16,8 @@ import 'pages/home_page.dart';
|
|||||||
import 'pages/player_page.dart';
|
import 'pages/player_page.dart';
|
||||||
import 'pages/playlist_page.dart';
|
import 'pages/playlist_page.dart';
|
||||||
import 'widgets/global_mini_player.dart';
|
import 'widgets/global_mini_player.dart';
|
||||||
|
import 'utils/navigation.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
final stopwatch = Stopwatch();
|
final stopwatch = Stopwatch();
|
||||||
|
|
||||||
bool _appInitialized = false;
|
bool _appInitialized = false;
|
||||||
@@ -53,6 +54,8 @@ void main() async {
|
|||||||
try {
|
try {
|
||||||
_audioHandler = AudioPlayerHandler();
|
_audioHandler = AudioPlayerHandler();
|
||||||
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
|
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
|
||||||
|
// ⭐ 将 Handler 注入到 AudioService
|
||||||
|
AudioService().setHandler(_audioHandler!);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ AudioPlayerHandler failed: $e');
|
debugPrint('❌ AudioPlayerHandler failed: $e');
|
||||||
debugPrint('$st');
|
debugPrint('$st');
|
||||||
@@ -363,65 +366,74 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
return MaterialApp(
|
// ⭐ 使用 AnnotatedRegion 包裹 MaterialApp,统一控制系统栏样式
|
||||||
title: '清听',
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
theme: ThemeData(
|
value: SystemUiOverlayStyle(
|
||||||
brightness: Brightness.dark,
|
statusBarColor: Colors.transparent,
|
||||||
primaryColor: const Color(0xFF7C9A9E),
|
statusBarIconBrightness: Brightness.light,
|
||||||
colorScheme: const ColorScheme.dark(
|
systemNavigationBarColor: const Color(0xFF1A1F1E),
|
||||||
primary: Color(0xFF7C9A9E),
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
secondary: Color(0xFFB8D4D0),
|
|
||||||
surface: Color(0xFF1A1F1E),
|
|
||||||
onSurface: Colors.white,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
),
|
||||||
navigatorKey: navigatorKey,
|
child: MaterialApp(
|
||||||
navigatorObservers: [MiniPlayerNavigatorObserver()],
|
title: '清听',
|
||||||
onGenerateRoute: _onGenerateRoute,
|
theme: ThemeData(
|
||||||
builder: (context, child) {
|
brightness: Brightness.dark,
|
||||||
return Stack(
|
primaryColor: const Color(0xFF7C9A9E),
|
||||||
fit: StackFit.expand,
|
colorScheme: const ColorScheme.dark(
|
||||||
children: [
|
primary: Color(0xFF7C9A9E),
|
||||||
if (child != null) child,
|
secondary: Color(0xFFB8D4D0),
|
||||||
AnimatedBuilder(
|
surface: Color(0xFF1A1F1E),
|
||||||
animation: RouteManager(),
|
onSurface: Colors.white,
|
||||||
builder: (context, _) {
|
),
|
||||||
final routeManager = RouteManager();
|
useMaterial3: true,
|
||||||
final shouldShow = routeManager.currentRoute != '/player' &&
|
),
|
||||||
routeManager.currentRoute != '/playlist';
|
navigatorKey: navigatorKey,
|
||||||
|
navigatorObservers: [MiniPlayerNavigatorObserver()],
|
||||||
|
onGenerateRoute: _onGenerateRoute,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (child != null) child,
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: RouteManager(),
|
||||||
|
builder: (context, _) {
|
||||||
|
final routeManager = RouteManager();
|
||||||
|
final shouldShow = routeManager.currentRoute != '/player' &&
|
||||||
|
routeManager.currentRoute != '/playlist';
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
switchInCurve: Curves.easeOut,
|
switchInCurve: Curves.easeOut,
|
||||||
switchOutCurve: Curves.easeIn,
|
switchOutCurve: Curves.easeIn,
|
||||||
transitionBuilder: (child, animation) {
|
transitionBuilder: (child, animation) {
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
begin: const Offset(0, 0.1),
|
begin: const Offset(0, 0.1),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(animation),
|
).animate(animation),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: shouldShow
|
|
||||||
? const Align(
|
|
||||||
key: ValueKey('mini_player_visible'),
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: GlobalMiniPlayer(),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(
|
|
||||||
key: ValueKey('mini_player_hidden'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
child: shouldShow
|
||||||
],
|
? const Align(
|
||||||
);
|
key: ValueKey('mini_player_visible'),
|
||||||
},
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: GlobalMiniPlayer(),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(
|
||||||
|
key: ValueKey('mini_player_hidden'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 @@ 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 '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 +22,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 +59,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']}');
|
||||||
|
|
||||||
@@ -105,57 +120,97 @@ class MetadataService {
|
|||||||
await _memoryCache.put(fileId, metadata);
|
await _memoryCache.put(fileId, metadata);
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-26
@@ -2,9 +2,10 @@ 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/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 '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';
|
||||||
@@ -34,11 +35,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 : '未知艺术家';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
safeSetState(() => _isLoading = true);
|
safeSetState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
await WebDAVService.instance.loadCredentials();
|
await WebDAVService.instance.loadCredentials();
|
||||||
_favorites = [];
|
await _loadFavoritesFromDb();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_favorites = [];
|
_favorites = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -244,18 +244,62 @@ 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,
|
||||||
|
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 +314,16 @@ 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,
|
||||||
|
url: url,
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -363,7 +416,14 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
),
|
),
|
||||||
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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -406,6 +466,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
builder: (_) => const WebDAVFileListPage(),
|
builder: (_) => const WebDAVFileListPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _loadFavoritesFromDb();
|
||||||
_resetSnapState();
|
_resetSnapState();
|
||||||
safeSetState(() {});
|
safeSetState(() {});
|
||||||
} else {
|
} else {
|
||||||
@@ -664,12 +725,11 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final song = _favorites[index];
|
final song = _favorites[index];
|
||||||
final itemProgress =
|
|
||||||
(_listReveal * 2 - index / 5)
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
|
// 收藏项不做滚动渐显:_listReveal 在首屏为 0,
|
||||||
|
// 会让条目完全透明(看起来"收藏了但不显示")。
|
||||||
return AnimatedOpacity(
|
return AnimatedOpacity(
|
||||||
opacity: itemProgress,
|
opacity: 1.0,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -703,13 +763,14 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.favorite,
|
Icons.favorite,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFE05C5C),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
safeSetState(() {
|
await SongDatabase()
|
||||||
_favorites.removeAt(index);
|
.removeFavorite(song.path);
|
||||||
});
|
await _loadFavoritesFromDb();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onTap: () => _playSong(song),
|
onTap: () => _playSong(song),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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 '../widgets/song_artwork.dart';
|
||||||
|
|
||||||
class WebDAVFileListPage extends StatefulWidget {
|
class WebDAVFileListPage extends StatefulWidget {
|
||||||
final String currentPath;
|
final String currentPath;
|
||||||
@@ -20,6 +23,13 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
String _errorMessage = '';
|
String _errorMessage = '';
|
||||||
String _currentPath = '/';
|
String _currentPath = '/';
|
||||||
|
|
||||||
|
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
|
||||||
|
Map<String, Map<String, dynamic>> _metaByPath = {};
|
||||||
|
String? _lastSyncedSongPath;
|
||||||
|
|
||||||
|
/// 已收藏的歌曲路径
|
||||||
|
Set<String> _favoritePaths = {};
|
||||||
|
|
||||||
// 安全解码
|
// 安全解码
|
||||||
String _safeDecode(String input) {
|
String _safeDecode(String input) {
|
||||||
try {
|
try {
|
||||||
@@ -33,9 +43,59 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentPath = widget.currentPath;
|
_currentPath = widget.currentPath;
|
||||||
|
AudioService().addListener(_onPlaybackChanged);
|
||||||
_loadDirectory();
|
_loadDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
AudioService().removeListener(_onPlaybackChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
|
||||||
|
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);
|
||||||
|
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 +108,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) {
|
||||||
@@ -295,15 +384,27 @@ 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, vertical: 2),
|
||||||
leading: const Icon(
|
leading: SongArtwork(
|
||||||
Icons.audiotrack,
|
filePath: meta?['artwork_path'] as String?,
|
||||||
color: Color(0xFFB8D4D0),
|
size: 44,
|
||||||
size: 28,
|
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,15 +414,23 @@ 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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,49 +4,62 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||||
import '../audio/player_controller.dart';
|
|
||||||
import '../audio/playback_state_manager.dart';
|
|
||||||
import '../services/audio_service.dart';
|
|
||||||
import '../utils/file_provider_utils.dart';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'playback_service.dart';
|
||||||
|
import 'audio_service.dart';
|
||||||
|
import '../utils/file_provider_utils.dart';
|
||||||
|
|
||||||
|
/// 音频服务处理器:连接 media_kit 播放引擎与 Android 系统媒体会话
|
||||||
|
/// 职责:发布播放状态到系统通知栏,接收系统媒体按钮事件
|
||||||
|
/// 它不拥有 Player 实例,所有播放控制均委托给 PlaybackService
|
||||||
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
||||||
final PlayerController _player = PlayerController();
|
final PlaybackService _playback = PlaybackService();
|
||||||
final PlaybackStateManager _state = PlaybackStateManager();
|
|
||||||
|
|
||||||
|
// 当前媒体信息
|
||||||
String? _currentId;
|
String? _currentId;
|
||||||
String? _currentTitle;
|
String? _currentTitle;
|
||||||
String? _currentArtist;
|
String? _currentArtist;
|
||||||
|
|
||||||
Duration _currentPosition = Duration.zero;
|
Duration _currentPosition = Duration.zero;
|
||||||
DateTime _lastPublishTime = DateTime.now();
|
DateTime _lastPublishTime = DateTime.now();
|
||||||
static const Duration _publishInterval = Duration(milliseconds: 500);
|
static const Duration _publishInterval = Duration(milliseconds: 500);
|
||||||
|
|
||||||
// ⭐ 缓存 artwork 文件路径,避免重复写入
|
|
||||||
String? _currentArtworkPath;
|
String? _currentArtworkPath;
|
||||||
|
bool _isPublishing = false;
|
||||||
|
|
||||||
AudioPlayerHandler() {
|
AudioPlayerHandler() {
|
||||||
_player.playingStream.listen((playing) {
|
_bindToPlayer();
|
||||||
_state.updatePlaying(playing);
|
}
|
||||||
_publishState();
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 绑定播放器状态流
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
void _bindToPlayer() {
|
||||||
|
final player = _playback.player;
|
||||||
|
|
||||||
|
player.stream.playing.listen((playing) {
|
||||||
|
_publishPlaybackState(
|
||||||
|
playing: playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
duration: player.state.duration,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
_player.positionStream.listen((position) {
|
player.stream.position.listen((position) {
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_state.updatePosition(position);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
||||||
_lastPublishTime = now;
|
_lastPublishTime = now;
|
||||||
// ⭐ 只保留 _publishStateOnly()
|
_publishPlaybackState(
|
||||||
_publishStateOnly();
|
playing: player.state.playing,
|
||||||
|
position: position,
|
||||||
|
duration: player.state.duration,
|
||||||
|
onlyPosition: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_player.durationStream.listen((duration) {
|
player.stream.duration.listen((duration) {
|
||||||
debugPrint('🎯 [durationStream] duration=$duration');
|
|
||||||
_state.updateDuration(duration);
|
|
||||||
if (_currentId != null && _currentTitle != null) {
|
if (_currentId != null && _currentTitle != null) {
|
||||||
_updateMediaItem(
|
_updateMediaItem(
|
||||||
id: _currentId!,
|
id: _currentId!,
|
||||||
@@ -55,45 +68,97 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_publishState();
|
_publishPlaybackState(
|
||||||
|
playing: player.state.playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 发布状态 ----
|
// ═══════════════════════════════════════════════════════════════
|
||||||
void _publishState() {
|
// 外部同步接口(由 AudioService 调用)
|
||||||
final state = _state.playbackState;
|
// ═══════════════════════════════════════════════════════════════
|
||||||
playbackState.add(audio_service.PlaybackState(
|
void syncState(Song? song) {
|
||||||
controls: state.controls,
|
if (song == null) {
|
||||||
processingState: state.processingState,
|
_currentId = null;
|
||||||
playing: state.playing,
|
_currentTitle = null;
|
||||||
androidCompactActionIndices: state.androidCompactActionIndices,
|
_currentArtist = null;
|
||||||
updatePosition: _currentPosition,
|
return;
|
||||||
updateTime: DateTime.now(),
|
}
|
||||||
systemActions: const {
|
|
||||||
audio_service.MediaAction.seek,
|
_updateMediaItem(
|
||||||
},
|
id: song.id,
|
||||||
));
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
artwork: song.artwork,
|
||||||
|
);
|
||||||
|
|
||||||
|
final player = _playback.player;
|
||||||
|
_publishPlaybackState(
|
||||||
|
playing: player.state.playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
duration: player.state.duration,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _publishStateOnly() {
|
// ═══════════════════════════════════════════════════════════════
|
||||||
final current = playbackState.value;
|
// 状态发布
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
void _publishPlaybackState({
|
||||||
|
required bool playing,
|
||||||
|
required Duration position,
|
||||||
|
required Duration duration,
|
||||||
|
bool onlyPosition = false,
|
||||||
|
}) {
|
||||||
|
if (_isPublishing) return;
|
||||||
|
_isPublishing = true;
|
||||||
|
|
||||||
playbackState.add(audio_service.PlaybackState(
|
try {
|
||||||
controls: current.controls.isNotEmpty
|
final controls = _buildControls(playing);
|
||||||
? current.controls
|
// audio_service 侧映射:idle -> STATE_NONE,ready -> PLAYING/PAUSED。
|
||||||
: _state.playbackState.controls,
|
// 之前只按 duration>0 判断,直播流/时长未解析出来时会被系统判定为"未在播放"
|
||||||
processingState: _state.playbackState.processingState,
|
// (播放设备选择页、部分车机与耳机都会读到这个状态)。
|
||||||
playing: current.playing,
|
final prepared = playing || duration.inMilliseconds > 0;
|
||||||
androidCompactActionIndices: current.androidCompactActionIndices,
|
final state = audio_service.PlaybackState(
|
||||||
updatePosition: _currentPosition,
|
controls: controls,
|
||||||
updateTime: DateTime.now(),
|
processingState: prepared
|
||||||
systemActions: const {
|
? audio_service.AudioProcessingState.ready
|
||||||
audio_service.MediaAction.seek,
|
: audio_service.AudioProcessingState.idle,
|
||||||
},
|
playing: playing,
|
||||||
));
|
updatePosition: position,
|
||||||
|
updateTime: DateTime.now(),
|
||||||
|
systemActions: const {audio_service.MediaAction.seek},
|
||||||
|
);
|
||||||
|
playbackState.add(state);
|
||||||
|
} finally {
|
||||||
|
_isPublishing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 更新媒体信息 ----
|
List<audio_service.MediaControl> _buildControls(bool playing) {
|
||||||
|
return [
|
||||||
|
const audio_service.MediaControl(
|
||||||
|
androidIcon: 'drawable/ic_previous',
|
||||||
|
label: '上一曲',
|
||||||
|
action: audio_service.MediaAction.skipToPrevious,
|
||||||
|
),
|
||||||
|
audio_service.MediaControl(
|
||||||
|
androidIcon: playing ? 'drawable/ic_pause' : 'drawable/ic_play',
|
||||||
|
label: playing ? '暂停' : '播放',
|
||||||
|
action: audio_service.MediaAction.playPause,
|
||||||
|
),
|
||||||
|
const audio_service.MediaControl(
|
||||||
|
androidIcon: 'drawable/ic_next',
|
||||||
|
label: '下一曲',
|
||||||
|
action: audio_service.MediaAction.skipToNext,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 媒体信息更新
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
void _updateMediaItem({
|
void _updateMediaItem({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
@@ -101,21 +166,15 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
Duration? duration,
|
Duration? duration,
|
||||||
Uint8List? artwork,
|
Uint8List? artwork,
|
||||||
}) {
|
}) {
|
||||||
debugPrint(
|
|
||||||
'📢 [handler] _updateMediaItem: artwork is ${artwork != null ? 'not null (${artwork.length} bytes)' : 'null'}');
|
|
||||||
_currentId = id;
|
_currentId = id;
|
||||||
_currentTitle = title;
|
_currentTitle = title;
|
||||||
_currentArtist = artist;
|
_currentArtist = artist;
|
||||||
final position = _player.position;
|
|
||||||
|
|
||||||
debugPrint('📢 [handler] updateMediaItem: $title - $artist');
|
final position = _playback.player.state.position;
|
||||||
|
|
||||||
// ⭐ 异步处理 artwork(不阻塞主流程)
|
|
||||||
_handleArtwork(id, artwork).then((artUri) {
|
_handleArtwork(id, artwork).then((artUri) {
|
||||||
// 如果 artUri 变化,重新推送 MediaItem
|
|
||||||
final current = mediaItem.value;
|
final current = mediaItem.value;
|
||||||
if (current != null && current.artUri != artUri) {
|
if (current != null && current.artUri != artUri) {
|
||||||
debugPrint('📢 [handler] updating artUri: $artUri');
|
|
||||||
mediaItem.add(audio_service.MediaItem(
|
mediaItem.add(audio_service.MediaItem(
|
||||||
id: current.id,
|
id: current.id,
|
||||||
title: current.title,
|
title: current.title,
|
||||||
@@ -127,38 +186,28 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 先推送不带封面图的 MediaItem(让 UI 尽快显示)
|
|
||||||
mediaItem.add(audio_service.MediaItem(
|
mediaItem.add(audio_service.MediaItem(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
artist: artist,
|
artist: artist,
|
||||||
duration: duration ?? _player.duration,
|
duration: duration ?? _playback.player.state.duration,
|
||||||
extras: {'position': position.inMilliseconds},
|
extras: {'position': position.inMilliseconds},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理封面图:保存到本地并生成 content URI
|
|
||||||
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
||||||
if (artwork == null || artwork.isEmpty) {
|
if (artwork == null || artwork.isEmpty) return null;
|
||||||
_currentArtworkPath = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final artworkDir = Directory('${dir.path}/artworks');
|
final artworkDir = Directory('${dir.path}/artworks');
|
||||||
if (!await artworkDir.exists()) {
|
if (!await artworkDir.exists()) {
|
||||||
await artworkDir.create(recursive: true);
|
await artworkDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 md5 生成安全的文件名
|
|
||||||
final bytes = utf8.encode(id);
|
final bytes = utf8.encode(id);
|
||||||
final digest = md5.convert(bytes);
|
final digest = md5.convert(bytes);
|
||||||
final fileName = '$digest.jpg';
|
final fileName = '$digest.jpg';
|
||||||
final path = '${artworkDir.path}/$fileName';
|
final path = '${artworkDir.path}/$fileName';
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|
||||||
// 检查文件是否已存在
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
final existingBytes = await file.readAsBytes();
|
final existingBytes = await file.readAsBytes();
|
||||||
if (existingBytes.length == artwork.length &&
|
if (existingBytes.length == artwork.length &&
|
||||||
@@ -167,12 +216,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
return await FileProviderUtils.getContentUri(file);
|
return await FileProviderUtils.getContentUri(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入新文件
|
|
||||||
await file.writeAsBytes(artwork);
|
await file.writeAsBytes(artwork);
|
||||||
_currentArtworkPath = path;
|
_currentArtworkPath = path;
|
||||||
debugPrint('📢 [handler] artwork saved: $path');
|
|
||||||
|
|
||||||
return await FileProviderUtils.getContentUri(file);
|
return await FileProviderUtils.getContentUri(file);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
||||||
@@ -180,50 +225,55 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 外部接口 ----
|
|
||||||
void updateNotification({
|
void updateNotification({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
required String artist,
|
required String artist,
|
||||||
Uint8List? artwork,
|
Uint8List? artwork,
|
||||||
}) {
|
}) {
|
||||||
_updateMediaItem(
|
_updateMediaItem(id: id, title: title, artist: artist, artwork: artwork);
|
||||||
id: id,
|
final player = _playback.player;
|
||||||
title: title,
|
_publishPlaybackState(
|
||||||
artist: artist,
|
playing: player.state.playing,
|
||||||
artwork: artwork,
|
position: _currentPosition,
|
||||||
|
duration: player.state.duration,
|
||||||
);
|
);
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 控制命令 ----
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// audio_service 控制命令(全部委托给 PlaybackService)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
debugPrint('▶️ [handler] play() called');
|
debugPrint('▶️ [handler] play() called');
|
||||||
_player.play();
|
// 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
|
||||||
_publishState();
|
await AudioService().playOrResume();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
debugPrint('⏸️ [handler] pause() called');
|
debugPrint('⏸️ [handler] pause() called');
|
||||||
_player.pause();
|
await _playback.pause();
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
debugPrint('⏹️ [handler] stop() called');
|
debugPrint('⏹️ [handler] stop() called');
|
||||||
_player.stop();
|
await _playback.stop();
|
||||||
_publishState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
debugPrint('⏩ [handler] seek() called: $position');
|
debugPrint('⏩ [handler] seek() called: $position');
|
||||||
await _player.seek(position);
|
await _playback.seek(position);
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_publishStateOnly();
|
final player = _playback.player;
|
||||||
|
_publishPlaybackState(
|
||||||
|
playing: player.state.playing,
|
||||||
|
position: position,
|
||||||
|
duration: player.state.duration,
|
||||||
|
onlyPosition: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -251,14 +301,14 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
await skipToPrevious();
|
await skipToPrevious();
|
||||||
break;
|
break;
|
||||||
case audio_service.MediaButton.media:
|
case audio_service.MediaButton.media:
|
||||||
if (_player.isPlaying) {
|
if (_playback.player.state.playing) {
|
||||||
await pause();
|
await pause();
|
||||||
} else {
|
} else {
|
||||||
await play();
|
await play();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (_player.isPlaying) {
|
if (_playback.player.state.playing) {
|
||||||
await pause();
|
await pause();
|
||||||
} else {
|
} else {
|
||||||
await play();
|
await play();
|
||||||
|
|||||||
+439
-67
@@ -7,12 +7,16 @@ 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 '../repositories/playlist_repository.dart';
|
import '../repositories/playlist_repository.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import 'webdav_service.dart';
|
import 'webdav_service.dart';
|
||||||
|
import 'audio_player_handler.dart';
|
||||||
|
|
||||||
enum PlayMode {
|
enum PlayMode {
|
||||||
sequential,
|
sequential,
|
||||||
@@ -62,6 +66,9 @@ class AudioService extends ChangeNotifier {
|
|||||||
// ---- 当前播放的歌单 ID ----
|
// ---- 当前播放的歌单 ID ----
|
||||||
String? _currentPlaylistId;
|
String? _currentPlaylistId;
|
||||||
|
|
||||||
|
// ⭐ 插入位置:在现有成员变量之后,方法之前
|
||||||
|
AudioPlayerHandler? _handler; // ⭐ 添加这一行
|
||||||
|
|
||||||
// ---- 高频进度 ----
|
// ---- 高频进度 ----
|
||||||
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
|
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
|
||||||
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
|
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
|
||||||
@@ -76,6 +83,51 @@ 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 的 playing 已经是 true,但 position 永远是 0、
|
||||||
|
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||||
|
DateTime? _trackStartAt;
|
||||||
|
DateTime? _playReadyAt;
|
||||||
|
int _prefetchedGeneration = -1;
|
||||||
|
|
||||||
|
/// 元数据回退到文件名时置位,播到 80% 再试一次
|
||||||
|
bool _metadataNeedsRetry = false;
|
||||||
|
int _metadataRetryGeneration = -1;
|
||||||
|
bool _firstAudioReported = false;
|
||||||
|
|
||||||
|
/// 冷启动恢复后尚未真正打开媒体:首次点击播放走 _playCurrent(含 seek)
|
||||||
|
bool _restoredPendingOpen = false;
|
||||||
|
|
||||||
|
/// 播放中定时持久化位置(冷启动能回到准确位置)
|
||||||
|
DateTime _lastPositionSaveAt = DateTime.now();
|
||||||
|
|
||||||
|
/// 打开失败后的快速重试(每首歌最多一次)
|
||||||
|
int _openRetryGeneration = -1;
|
||||||
|
|
||||||
|
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
|
||||||
|
String? _lastPlaybackError;
|
||||||
|
String? get lastPlaybackError => _lastPlaybackError;
|
||||||
|
|
||||||
int _playbackGeneration = 0;
|
int _playbackGeneration = 0;
|
||||||
|
|
||||||
// ---- 待恢复的播放进度 ----
|
// ---- 待恢复的播放进度 ----
|
||||||
@@ -115,6 +167,11 @@ class AudioService extends ChangeNotifier {
|
|||||||
_onSongChanged = callback;
|
_onSongChanged = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHandler(AudioPlayerHandler handler) {
|
||||||
|
// ⭐ 添加这个方法
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
void togglePlayMode() {
|
void togglePlayMode() {
|
||||||
switch (_playMode) {
|
switch (_playMode) {
|
||||||
case PlayMode.sequential:
|
case PlayMode.sequential:
|
||||||
@@ -243,16 +300,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
||||||
|
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
||||||
|
final epoch = ++_switchEpoch;
|
||||||
|
_restoredPendingOpen = false;
|
||||||
_isChangingTrack = true;
|
_isChangingTrack = true;
|
||||||
_handlingCompletion = false;
|
_handlingCompletion = false;
|
||||||
_hasStartedCurrentPlayback = false;
|
_hasStartedCurrentPlayback = false;
|
||||||
@@ -264,6 +320,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
final song = _queue[_currentIndex];
|
final song = _queue[_currentIndex];
|
||||||
_currentSong = song;
|
_currentSong = song;
|
||||||
|
|
||||||
|
_handler?.syncState(song);
|
||||||
|
|
||||||
_startListening();
|
_startListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -271,9 +329,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) {
|
||||||
@@ -287,17 +370,254 @@ 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;
|
||||||
|
debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试');
|
||||||
|
Timer(const Duration(milliseconds: 1500), () {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
_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;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (!force &&
|
||||||
|
_lastStallRecoveryAt != null &&
|
||||||
|
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
|
||||||
|
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastStallRecoveryAt = now;
|
||||||
|
|
||||||
|
final resumeAt = positionNotifier.value;
|
||||||
|
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
||||||
|
try {
|
||||||
|
_trackStartAt = DateTime.now();
|
||||||
|
_playReadyAt = null;
|
||||||
|
_firstAudioReported = false;
|
||||||
|
await _playWithHeaders(url);
|
||||||
|
_playReadyAt = DateTime.now();
|
||||||
|
if (resumeAt > Duration.zero) {
|
||||||
|
await PlaybackService().seek(resumeAt);
|
||||||
|
positionNotifier.value = resumeAt;
|
||||||
|
}
|
||||||
|
debugPrint('✅ [AudioService] 自动恢复完成');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
||||||
|
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
|
||||||
|
///
|
||||||
|
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
|
||||||
|
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
|
||||||
|
void _onNetworkChanged() {
|
||||||
|
_networkDebounce?.cancel();
|
||||||
|
_networkDebounce = Timer(const Duration(seconds: 2), () {
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _waitForPlaybackStarted() async {
|
Future<void> _waitForPlaybackStarted() async {
|
||||||
final player = PlaybackService().player;
|
final player = PlaybackService().player;
|
||||||
|
|
||||||
@@ -340,9 +660,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
String _generateSongKey(String url) {
|
||||||
final raw = '$url|$fileSize|$modifiedTime';
|
return songKeyFor(url);
|
||||||
return raw.hashCode.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -394,6 +713,15 @@ 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,
|
||||||
@@ -431,13 +759,12 @@ class AudioService extends ChangeNotifier {
|
|||||||
final file = await provider.getFile(url);
|
final file = await provider.getFile(url);
|
||||||
if (file != null && await file.exists()) {
|
if (file != null && await file.exists()) {
|
||||||
final stat = await file.stat();
|
final stat = await file.stat();
|
||||||
songKey = _generateSongKey(
|
songKey = _generateSongKey(url);
|
||||||
url, stat.size, stat.modified.millisecondsSinceEpoch);
|
|
||||||
} else {
|
} else {
|
||||||
songKey = url.hashCode.toString();
|
songKey = songKeyForUrl(url);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
songKey = url.hashCode.toString();
|
songKey = songKeyForUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
final db = SongDatabase();
|
final db = SongDatabase();
|
||||||
@@ -450,7 +777,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final cacheDir = await getTemporaryDirectory();
|
final cacheDir = await getTemporaryDirectory();
|
||||||
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
|
||||||
final cacheFile = File(cachePath);
|
final cacheFile = File(cachePath);
|
||||||
if (await cacheFile.exists()) {
|
if (await cacheFile.exists()) {
|
||||||
await cacheFile.delete();
|
await cacheFile.delete();
|
||||||
@@ -483,11 +810,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isChangingTrack) {
|
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
|
||||||
debugPrint('⚠️ [next] REJECTED: track switch in progress');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_playMode == PlayMode.shuffle) {
|
if (_playMode == PlayMode.shuffle) {
|
||||||
if (_shuffledIndices.isEmpty) return;
|
if (_shuffledIndices.isEmpty) return;
|
||||||
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||||
@@ -505,12 +828,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
Future<void> previous() async {
|
Future<void> previous() async {
|
||||||
if (_queue.isEmpty) return;
|
if (_queue.isEmpty) return;
|
||||||
|
|
||||||
if (_isChangingTrack) {
|
// 同 next():最新请求胜出,不拒绝
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AudioService] previous() ignored: track switch in progress');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_playMode == PlayMode.shuffle) {
|
if (_playMode == PlayMode.shuffle) {
|
||||||
if (_shuffledIndices.isEmpty) return;
|
if (_shuffledIndices.isEmpty) return;
|
||||||
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
|
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
|
||||||
@@ -539,14 +857,35 @@ 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;
|
||||||
|
|
||||||
|
if (_restoredPendingOpen) {
|
||||||
|
_restoredPendingOpen = false;
|
||||||
|
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
|
||||||
|
await _playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PlaybackService().resume();
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopPlay() {
|
void stopPlay() {
|
||||||
|
// 停止底层播放器
|
||||||
|
PlaybackService().stop(); // 新增
|
||||||
_currentSong = null;
|
_currentSong = null;
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
positionNotifier.value = Duration.zero;
|
positionNotifier.value = Duration.zero;
|
||||||
@@ -554,7 +893,6 @@ class AudioService extends ChangeNotifier {
|
|||||||
bufferedNotifier.value = Duration.zero;
|
bufferedNotifier.value = Duration.zero;
|
||||||
_stopListening();
|
_stopListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
// 停止时也保存一次
|
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +972,19 @@ class AudioService extends ChangeNotifier {
|
|||||||
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
||||||
|
// 与 play() 发起时间相减即可算出起播延迟。
|
||||||
|
if (!_firstAudioReported && position > Duration.zero) {
|
||||||
|
_firstAudioReported = true;
|
||||||
|
final readyAt = _playReadyAt;
|
||||||
|
if (readyAt != null) {
|
||||||
|
final ms = DateTime.now().difference(readyAt).inMilliseconds;
|
||||||
|
debugPrint('🔊 [AudioService] 起播耗时 ${ms}ms(open 完成 -> 出声)');
|
||||||
|
_playReadyAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_maybeRetryMetadata(position);
|
||||||
|
_maybePrefetchNext(position);
|
||||||
positionNotifier.value = position;
|
positionNotifier.value = position;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -653,6 +1004,47 @@ class AudioService extends ChangeNotifier {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
|
||||||
|
_subscriptions.add(
|
||||||
|
player.stream.error.listen((error) {
|
||||||
|
debugPrint('❌ [AudioService] player error: $error');
|
||||||
|
_lastPlaybackError = error;
|
||||||
|
notifyListeners();
|
||||||
|
// 打开失败(ffurl_read / Failed to open)时立刻安排一次重试,
|
||||||
|
// 不要等 10~20s 的起播看门狗。
|
||||||
|
if (error.contains('Failed to open') ||
|
||||||
|
error.contains('ffurl_read')) {
|
||||||
|
_scheduleOpenRetry();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缓冲状态:只记录"最近一次进入缓冲的时间"。
|
||||||
|
// 不能用 Timeout 重置计时:弱网下 buffering 会 true/false 反复抖动,
|
||||||
|
// 每次 true 都重置的话,30s 阈值永远不会到。
|
||||||
|
_subscriptions.add(
|
||||||
|
player.stream.buffering.listen((buffering) {
|
||||||
|
debugPrint('🌊 [AudioService] buffering=$buffering');
|
||||||
|
if (buffering) {
|
||||||
|
_lastBufferingAt = DateTime.now();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 进度看门狗:播放中进度 30s 不动、且近期发生过缓冲 → 判定卡死并自愈。
|
||||||
|
// 用"进度 + 最近缓冲"两个条件,既不会在暂停时误触发,
|
||||||
|
// 也能覆盖 buffering 反复抖动、始终起不来的情况。
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_lastProgressAt = DateTime.now();
|
||||||
|
_progressTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
(_) => _checkPlaybackProgress(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 网络变化监听:换 Wi-Fi / 切移动数据后长连接会失效,主动重连
|
||||||
|
NetworkWatcher().onChanged = _onNetworkChanged;
|
||||||
|
NetworkWatcher().start();
|
||||||
|
|
||||||
// ⭐ 完整生命周期校验的 completed 监听
|
// ⭐ 完整生命周期校验的 completed 监听
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
player.stream.completed.listen((_) {
|
player.stream.completed.listen((_) {
|
||||||
@@ -821,11 +1213,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
_currentSong = song;
|
_currentSong = song;
|
||||||
_onSongChanged?.call(song);
|
_onSongChanged?.call(song);
|
||||||
|
|
||||||
|
_handler?.syncState(song); // ⭐ 添加这一行
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
|
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
|
||||||
|
|
||||||
// ⭐ 关键:加载音频但不自动播放
|
// 冷启动只恢复 UI 状态,不联网预加载。
|
||||||
await _loadRestoredPlayback();
|
// 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
|
||||||
|
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
|
||||||
|
_restoredPendingOpen = true;
|
||||||
|
_isPlaying = false;
|
||||||
|
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -834,44 +1233,17 @@ 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;
|
||||||
|
NetworkWatcher().stop();
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
positionNotifier.dispose();
|
positionNotifier.dispose();
|
||||||
|
|||||||
@@ -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,91 @@ 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;
|
_initialized = true;
|
||||||
|
debugPrint(
|
||||||
// 可以在这里配置初始参数
|
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
||||||
// _player.setVolume(1.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 播放 ----------
|
// ---------- 播放 ----------
|
||||||
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 +131,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 +140,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,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,3 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
@@ -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);
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../utils/navigation.dart'; // ⭐ 导入 navigatorKey
|
||||||
|
|
||||||
class GlobalMiniPlayer extends StatelessWidget {
|
class GlobalMiniPlayer extends StatelessWidget {
|
||||||
const GlobalMiniPlayer({super.key});
|
const GlobalMiniPlayer({super.key});
|
||||||
@@ -45,11 +46,9 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// ⭐ 使用 Navigator.of(context) 替代 navigatorKey
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
Navigator.of(context, rootNavigator: true)
|
navigatorKey.currentState?.pushNamed('/player');
|
||||||
.pushNamed('/player');
|
|
||||||
},
|
},
|
||||||
// ⭐ 替换弃用的 withOpacity 为 withValues
|
|
||||||
highlightColor: Colors.white.withValues(alpha: 0.05),
|
highlightColor: Colors.white.withValues(alpha: 0.05),
|
||||||
splashColor: Colors.white.withValues(alpha: 0.1),
|
splashColor: Colors.white.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -124,8 +123,8 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// ⭐ 使用 Navigator.of(context)
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
Navigator.of(context).pushNamed('/playlist');
|
navigatorKey.currentState?.pushNamed('/playlist');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// lib/widgets/song_artwork.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 歌曲封面:优先进内存字节,其次本地封面文件,最后回退图标。
|
||||||
|
///
|
||||||
|
/// 列表页一次会构建几十行,这里用 `existsSync` 判断一次即可,
|
||||||
|
/// 图片本身由 Flutter 按需解码。
|
||||||
|
class SongArtwork extends StatelessWidget {
|
||||||
|
final Uint8List? bytes;
|
||||||
|
final String? filePath;
|
||||||
|
final double size;
|
||||||
|
final IconData fallbackIcon;
|
||||||
|
final Color? fallbackColor;
|
||||||
|
|
||||||
|
const SongArtwork({
|
||||||
|
super.key,
|
||||||
|
this.bytes,
|
||||||
|
this.filePath,
|
||||||
|
this.size = 44,
|
||||||
|
this.fallbackIcon = Icons.music_note,
|
||||||
|
this.fallbackColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget child;
|
||||||
|
if (bytes != null && bytes!.isNotEmpty) {
|
||||||
|
child = Image.memory(
|
||||||
|
bytes!,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, __, ___) => _fallback(),
|
||||||
|
);
|
||||||
|
} else if (filePath != null &&
|
||||||
|
filePath!.isNotEmpty &&
|
||||||
|
File(filePath!).existsSync()) {
|
||||||
|
child = Image.file(
|
||||||
|
File(filePath!),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, __, ___) => _fallback(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
child = _fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(width: size, height: size, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallback() {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color: const Color(0xFF232928),
|
||||||
|
child: Icon(
|
||||||
|
fallbackIcon,
|
||||||
|
size: size * 0.55,
|
||||||
|
color: fallbackColor ?? const Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-32
@@ -13,10 +13,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.9"
|
version: "4.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -101,18 +101,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.3"
|
||||||
code_assets:
|
code_assets:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.0.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -220,10 +220,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.2.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -244,10 +244,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.9.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -268,10 +268,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: jni_flutter
|
name: jni_flutter
|
||||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.3"
|
||||||
jni_util:
|
jni_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -284,10 +284,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -412,10 +412,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mime
|
name: mime
|
||||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -428,10 +428,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.5.0"
|
version: "9.6.0"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -588,18 +588,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pub_semver
|
name: pub_semver
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.1"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_use
|
name: record_use
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "1.1.1"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -628,18 +628,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.27"
|
version: "2.4.28"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_foundation
|
name: shared_preferences_foundation
|
||||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.6"
|
version: "2.5.7"
|
||||||
shared_preferences_linux:
|
shared_preferences_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,10 +729,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.2"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -817,10 +817,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.3.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -849,10 +849,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: yaml
|
name: yaml
|
||||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
+2
-3
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.0+1
|
version: 1.1.0-dev.1+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
@@ -32,12 +32,11 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
media_kit: 1.2.6
|
media_kit: 1.2.6
|
||||||
media_kit_libs_audio: ^1.0.7
|
media_kit_libs_audio: ^1.0.7
|
||||||
#media_kit_libs_android_audio: 1.3.8
|
|
||||||
shared_preferences: 2.2.2
|
shared_preferences: 2.2.2
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
dio: ^5.4.0 # HTTP 客户端
|
dio: ^5.4.0 # HTTP 客户端
|
||||||
xml: ^6.5.0
|
xml: ^6.5.0
|
||||||
audio_service: ^0.18.19
|
audio_service: 0.18.19
|
||||||
audio_session: ^0.1.21
|
audio_session: ^0.1.21
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
flutter_cache_manager: ^3.3.1
|
flutter_cache_manager: ^3.3.1
|
||||||
|
|||||||
@@ -684,3 +684,6 @@ await AudioService.start(
|
|||||||
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
|
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
|
||||||
|
|
||||||
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
|
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
|
||||||
|
|
||||||
|
大樱子留:
|
||||||
|
26.10.22-23:22 多实例问题已修复,接下来会将代码合并到main分支,大肥鱼立功一件,有时候harness确实很有意义
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// test/metadata_normalizer_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_model.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_normalizer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final normalizer = MetadataNormalizer();
|
||||||
|
|
||||||
|
RawMetadata raw({
|
||||||
|
String title = '',
|
||||||
|
String artist = '',
|
||||||
|
String album = '',
|
||||||
|
int year = 0,
|
||||||
|
}) =>
|
||||||
|
RawMetadata(title: title, artist: artist, album: album, year: year);
|
||||||
|
|
||||||
|
group('normalize 字段来源', () {
|
||||||
|
test('标签齐全时三个字段都标记为来自标签', () {
|
||||||
|
final n = normalizer.normalize(
|
||||||
|
raw(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦'),
|
||||||
|
fileName: 'track01.mp3',
|
||||||
|
filePath: '/音乐/周杰伦/track01.mp3',
|
||||||
|
);
|
||||||
|
expect(n.title, '夜曲');
|
||||||
|
expect(n.artist, '周杰伦');
|
||||||
|
expect(n.album, '十一月的萧邦');
|
||||||
|
expect(n.titleFromTag, isTrue);
|
||||||
|
expect(n.artistFromTag, isTrue);
|
||||||
|
expect(n.albumFromTag, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有标签时回退到文件名与目录', () {
|
||||||
|
final n = normalizer.normalize(
|
||||||
|
raw(),
|
||||||
|
fileName: '晴天.mp3',
|
||||||
|
filePath: '/音乐/周杰伦/晴天.mp3',
|
||||||
|
);
|
||||||
|
expect(n.title, '晴天');
|
||||||
|
expect(n.artist, '周杰伦');
|
||||||
|
expect(n.titleFromTag, isFalse);
|
||||||
|
expect(n.artistFromTag, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('evaluate 置信度分级', () {
|
||||||
|
NormalizedMetadata norm({
|
||||||
|
required String title,
|
||||||
|
required String artist,
|
||||||
|
String album = '',
|
||||||
|
bool titleTag = true,
|
||||||
|
bool artistTag = true,
|
||||||
|
bool albumTag = false,
|
||||||
|
}) =>
|
||||||
|
NormalizedMetadata(
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
titleFromTag: titleTag,
|
||||||
|
artistFromTag: artistTag,
|
||||||
|
albumFromTag: albumTag,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('标题 + 艺术家 + 专辑 → 0.90', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.90, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有标题和艺术家 → 0.80', () {
|
||||||
|
final c = normalizer.evaluate(norm(title: '夜曲', artist: '周杰伦'), const []);
|
||||||
|
expect(c.confidence, closeTo(0.80, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有文件名(无标签)→ 0.35,低于阈值', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: 'track01', artist: '未知艺术家', titleTag: false, artistTag: false),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.35, 0.001));
|
||||||
|
expect(c.confidence < 0.6, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史一致性加权:同艺人 ≥2 条 → +0.05', () {
|
||||||
|
final history = [
|
||||||
|
{'artist': '周杰伦', 'album': ''},
|
||||||
|
{'artist': '周杰伦', 'album': ''},
|
||||||
|
];
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦'),
|
||||||
|
history,
|
||||||
|
);
|
||||||
|
expect(c.confidence, closeTo(0.85, 0.001));
|
||||||
|
expect(c.evidence['artist_history'], 0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('证据可解释:每一项加分都有记录', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
expect(c.evidence.containsKey('base'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('title_from_tag'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('artist_from_tag'), isTrue);
|
||||||
|
expect(c.evidence.containsKey('album'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('decide 阈值', () {
|
||||||
|
test('高于阈值时 source=normalized', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
const NormalizedMetadata(
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
titleFromTag: true,
|
||||||
|
artistFromTag: true,
|
||||||
|
),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
final f = normalizer.decide(c);
|
||||||
|
expect(f.source, 'normalized');
|
||||||
|
expect(f.title, '夜曲');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('低于阈值时 source=pending', () {
|
||||||
|
final c = normalizer.evaluate(
|
||||||
|
const NormalizedMetadata(
|
||||||
|
title: 'track01',
|
||||||
|
artist: '未知艺术家',
|
||||||
|
titleFromTag: false,
|
||||||
|
artistFromTag: false,
|
||||||
|
),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
final f = normalizer.decide(c);
|
||||||
|
expect(f.source, 'pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// test/metadata_stability_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_model.dart';
|
||||||
|
import 'package:qingting_player/metadata/metadata_stability.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
CandidateMetadata candidate({double confidence = 0.8}) => CandidateMetadata(
|
||||||
|
metadata: const NormalizedMetadata(title: '夜曲', artist: '周杰伦'),
|
||||||
|
confidence: confidence,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('MetadataStability', () {
|
||||||
|
test('首次见到 → pending,次数 1', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: null,
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'first_seen');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第二次结果一致且置信度达标 → verified', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
expect(d.reason, 'stable');
|
||||||
|
expect(d.validationCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('标题或艺术家冲突 → 次数重置为 1,保持 pending', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.9,
|
||||||
|
'validation_count': 5,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '完全不同的歌',
|
||||||
|
artist: '别的歌手',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'conflict');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('置信度不足 → pending,但次数继续累计', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.4,
|
||||||
|
'validation_count': 3,
|
||||||
|
},
|
||||||
|
candidate: candidate(confidence: 0.5),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'low_confidence');
|
||||||
|
expect(d.validationCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一致但只验证过一次 → pending', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '周杰伦',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 0,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'pending');
|
||||||
|
expect(d.reason, 'need_second_validation');
|
||||||
|
expect(d.validationCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字段缺失不算冲突(旧记录没有艺术家)', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': '夜曲',
|
||||||
|
'artist': '',
|
||||||
|
'confidence': 0.7,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: '夜曲',
|
||||||
|
artist: '周杰伦',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
expect(d.validationCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大小写与首尾空格不影响一致性判断', () {
|
||||||
|
final d = MetadataStability.decide(
|
||||||
|
previous: {
|
||||||
|
'title': ' Night Song ',
|
||||||
|
'artist': 'JAY',
|
||||||
|
'confidence': 0.8,
|
||||||
|
'validation_count': 1,
|
||||||
|
},
|
||||||
|
candidate: candidate(),
|
||||||
|
title: 'night song',
|
||||||
|
artist: 'jay',
|
||||||
|
);
|
||||||
|
expect(d.status, 'verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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