Compare commits
33
Commits
35be847ce7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aba004e43 | ||
|
|
cdf77b308d | ||
|
|
200067a3b2 | ||
|
|
974dee2995 | ||
|
|
b86a5d2b46 | ||
|
|
eb46c53f9a | ||
|
|
53f60d393f | ||
|
|
08ea8a4508 | ||
|
|
abffb77167 | ||
|
|
21a1ad1d05 | ||
|
|
02ca590c65 | ||
|
|
e102e07378 | ||
|
|
99917bac87 | ||
|
|
86b1d0df42 | ||
|
|
2e55f94df2 | ||
|
|
fa396ce681 | ||
|
|
f91846be9a | ||
|
|
bb74cd1351 | ||
|
|
6cb1167a21 | ||
|
|
9fec9b98c5 | ||
|
|
2a29e30940 | ||
|
|
e58be9c17f | ||
|
|
ad6617592e | ||
|
|
bca1288bd1 | ||
|
|
90aa7d10e9 | ||
|
|
055df89ca5 | ||
|
|
8f3f827429 | ||
|
|
94d37fe263 | ||
|
|
f2b4d1f46d | ||
|
|
0e8921742c | ||
|
|
7bd23bfee6 | ||
|
|
b75ea9e4ce | ||
|
|
bb466ca534 |
@@ -1,17 +1,49 @@
|
|||||||
# qt_player
|
# 清听 QingTing
|
||||||
|
|
||||||
A new Flutter project.
|
[](https://flutter.dev)
|
||||||
|
[](https://android.com)
|
||||||
|
|
||||||
## Getting Started
|
**让远程音乐,像本地音乐一样可靠。**
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
清听是一个面向 Android 的 WebDAV 音乐播放器。
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
它解决的事情很简单:
|
||||||
|
|
||||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
> **让存储在 WebDAV 服务器上的音频文件,获得稳定、连续、可控的本地级播放体验。**
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
## ✨ 能力
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
- WebDAV 流式播放,无需下载
|
||||||
|
- 后台播放,退出应用不停
|
||||||
|
- 通知中心、锁屏、耳机控制
|
||||||
|
- 元数据读取与 SQLite 缓存
|
||||||
|
- 通知栏封面图
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆
|
||||||
|
git clone https://your-repo/qingting.git
|
||||||
|
cd qingting
|
||||||
|
|
||||||
|
# 获取依赖
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
详细步骤:[Getting Started →](./docs/getting-started.md)
|
||||||
|
|
||||||
|
## 📖 文档
|
||||||
|
|
||||||
|
- [整体架构](./docs/architecture.md) —— 系统怎么组织的
|
||||||
|
- [播放链路](./docs/playback.md) —— 一首歌从点击到喇叭
|
||||||
|
- [元数据](./docs/metadata.md) —— 歌曲信息怎么被认识
|
||||||
|
- [Android 集成](./docs/android.md) —— 怎么成为 Android 播放器
|
||||||
|
- [存储策略](./docs/storage.md) —— 远程与本地如何共存
|
||||||
|
- [设计决策](./docs/decisions.md) —— 为什么选了这些方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最好的播放器,是当你开始听歌之后,就再也感觉不到它技术细节的那一个。
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
.settings
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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 {
|
android {
|
||||||
namespace = "com.lxh.qingting_player"
|
namespace = "com.lxh.qingting_player"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 36
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -17,19 +16,47 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.lxh.qingting_player"
|
applicationId = "com.lxh.qingting_player"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = 34
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
// ⬇️ 把 ndk 配置放在这里(defaultConfig 内部)
|
// 仅打包 arm64-v8a
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
|
abiFilters.addAll(listOf("arm64-v8a"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显式指定 jniLibs 源目录
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
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 {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
pickFirsts += "**/*.so"
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 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,51 +1,52 @@
|
|||||||
<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">
|
||||||
<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:label="清听"
|
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="清听"
|
||||||
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:exported="true"
|
android:exported="true"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme" />
|
android:resource="@style/NormalTheme" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- ⭐ 关键:AudioService 必须同时声明 MediaBrowserService 和 MEDIA_BUTTON -->
|
<!-- AudioService -->
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:exported="true"
|
||||||
android:exported="true">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<!-- 媒体浏览器服务(MediaButtonReceiver 需要) -->
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
<!-- 媒体按钮(通知栏、蓝牙、耳机按键) -->
|
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- ⭐ MediaButtonReceiver 接收系统媒体按钮事件 -->
|
<!-- MediaButtonReceiver -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -54,6 +55,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- FileProvider for artwork(已配置,保持不变) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
@@ -61,8 +73,9 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.lxh.qingting_player
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
requestAudioFocus()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// FileProvider MethodChannel for sharing artwork via content:// URI
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method == "getContentUri") {
|
||||||
|
val path = call.argument<String>("path")
|
||||||
|
if (path != null && path.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) {
|
||||||
|
result.error("FILE_NOT_FOUND", "File does not exist: $path", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
Log.d("QTPlayer", "📢 [FileProvider] content URI: $uri")
|
||||||
|
result.success(uri.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("QTPlayer", "❌ [FileProvider] error: ${e.message}")
|
||||||
|
result.error("ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PATH", "path is null or empty", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setHighRefreshRate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setHighRefreshRate() {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val params = window.attributes
|
||||||
|
params.preferredRefreshRate = 120f
|
||||||
|
window.attributes = params
|
||||||
|
Log.d("QTPlayer", "✅ 设置高刷 120Hz")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("QTPlayer", "设置高刷失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.lxh.qingting_player
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity // ⭐ 改用这个
|
|
||||||
|
|
||||||
class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivity
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setHighRefreshRate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
setHighRefreshRate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setHighRefreshRate() {
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
val params = window.attributes
|
|
||||||
params.preferredRefreshRate = 120f
|
|
||||||
window.attributes = params
|
|
||||||
Log.d("QTPlayer", "✅ 设置高刷 120Hz")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("QTPlayer", "设置高刷失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearRefreshRate() {
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
val params = window.attributes
|
|
||||||
params.preferredRefreshRate = 0f
|
|
||||||
window.attributes = params
|
|
||||||
Log.d("QTPlayer", "恢复默认刷新率")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("QTPlayer", "恢复默认刷新率失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- app_flutter 目录(getApplicationDocumentsDirectory) -->
|
||||||
|
<files-path name="app_flutter" path="../app_flutter/" />
|
||||||
|
<!-- 封面图目录 -->
|
||||||
|
<files-path name="artworks" path="../app_flutter/artworks/" />
|
||||||
|
<!-- 缓存目录 -->
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<!-- 外部文件目录(备用) -->
|
||||||
|
<external-files-path name="external" path="." />
|
||||||
|
</paths>
|
||||||
@@ -20,7 +20,7 @@ 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.0.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// lib/base/base_state.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 所有页面的基类,提供安全的 setState 封装
|
||||||
|
///
|
||||||
|
/// 使用方式:
|
||||||
|
/// 1. 将页面的 State 类从 `State<YourPage>` 改为 `BaseState<YourPage>`
|
||||||
|
/// 2. 将所有 `setState(() { ... })` 替换为 `safeSetState(() { ... })`
|
||||||
|
abstract class BaseState<T extends StatefulWidget> extends State<T> {
|
||||||
|
/// 安全的 setState,自动检查 mounted
|
||||||
|
///
|
||||||
|
/// 用法:
|
||||||
|
/// ```dart
|
||||||
|
/// safeSetState(() {
|
||||||
|
/// _isLoading = false;
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
void safeSetState(VoidCallback fn) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全执行异步操作,完成后自动刷新
|
||||||
|
///
|
||||||
|
/// 用法:
|
||||||
|
/// ```dart
|
||||||
|
/// await safeAsync(
|
||||||
|
/// () => _loadData(), // 异步操作
|
||||||
|
/// () => _isLoading = false, // 完成后执行的 setState
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
Future<void> safeAsync(
|
||||||
|
Future<void> Function() action,
|
||||||
|
VoidCallback onComplete, {
|
||||||
|
VoidCallback? onError,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
if (mounted) {
|
||||||
|
setState(onComplete);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [BaseState] safeAsync error: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(onComplete);
|
||||||
|
}
|
||||||
|
onError?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示 SnackBar(封装,确保 mounted)
|
||||||
|
void showSafeSnackBar(String message, {Color? backgroundColor}) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: backgroundColor ?? Colors.grey,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// lib/constants/ui_constants.dart
|
||||||
|
class UIConstants {
|
||||||
|
static const double miniPlayerHeight = 60;
|
||||||
|
static const double miniPlayerBottomSpace = 80; // 60 + 20 安全余量
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// lib/controllers/playback_controller.dart
|
||||||
|
import 'package:flutter/foundation.dart'; // ⭐ 添加
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/webdav_service.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
|
||||||
|
class PlaybackController {
|
||||||
|
static final PlaybackController _instance = PlaybackController._internal();
|
||||||
|
factory PlaybackController() => _instance;
|
||||||
|
PlaybackController._internal();
|
||||||
|
|
||||||
|
final AudioService _audioService = AudioService();
|
||||||
|
final SongDatabase _db = SongDatabase();
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 从 WebDAV 目录播放(点击歌曲)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> playFromWebDAV({
|
||||||
|
required String directoryPath,
|
||||||
|
required String clickedSongPath,
|
||||||
|
required List<WebDAVItem> allItems,
|
||||||
|
}) async {
|
||||||
|
// 1. 过滤出所有音乐文件(去掉目录)
|
||||||
|
final musicFiles = allItems.where((item) => !item.isDirectory).toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
|
||||||
|
if (musicFiles.isEmpty) return;
|
||||||
|
|
||||||
|
// 2. 调试信息
|
||||||
|
debugPrint('🎯 [PlaybackController] musicFiles:');
|
||||||
|
for (int i = 0; i < musicFiles.length; i++) {
|
||||||
|
debugPrint(' $i: ${musicFiles[i].path}');
|
||||||
|
}
|
||||||
|
debugPrint('🎯 [PlaybackController] clickedSongPath: $clickedSongPath');
|
||||||
|
|
||||||
|
// 3. 找到点击歌曲在音乐文件列表中的位置
|
||||||
|
final clickedIndex =
|
||||||
|
musicFiles.indexWhere((item) => item.path == clickedSongPath);
|
||||||
|
debugPrint('🎯 [PlaybackController] clickedIndex: $clickedIndex');
|
||||||
|
|
||||||
|
if (clickedIndex == -1) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [PlaybackController] clicked song not found, fallback to single play');
|
||||||
|
await _playSingleSong(clickedSongPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建 Song 队列
|
||||||
|
final queue = await _buildSongQueue(musicFiles);
|
||||||
|
|
||||||
|
// 5. 应用播放模式
|
||||||
|
final playMode = _audioService.playMode;
|
||||||
|
List<Song> finalQueue = List<Song>.from(queue);
|
||||||
|
int startIndex = clickedIndex;
|
||||||
|
|
||||||
|
if (playMode == PlayMode.shuffle) {
|
||||||
|
final shuffled = List<Song>.from(queue)..shuffle();
|
||||||
|
startIndex = shuffled.indexWhere((s) => s.id == clickedSongPath);
|
||||||
|
if (startIndex == -1) startIndex = 0;
|
||||||
|
finalQueue = shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🎯 [PlaybackController] finalQueue length: ${finalQueue.length}, startIndex: $startIndex');
|
||||||
|
|
||||||
|
_audioService.setQueue(finalQueue, startIndex: startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 从 WebDAV 目录播放(全部播放)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> playAllFromWebDAV({
|
||||||
|
required String directoryPath,
|
||||||
|
required List<WebDAVItem> allItems,
|
||||||
|
}) async {
|
||||||
|
final musicFiles = allItems.where((item) => !item.isDirectory).toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
|
||||||
|
if (musicFiles.isEmpty) return;
|
||||||
|
|
||||||
|
final queue = await _buildSongQueue(musicFiles);
|
||||||
|
|
||||||
|
final playMode = _audioService.playMode;
|
||||||
|
List<Song> finalQueue = List<Song>.from(queue);
|
||||||
|
if (playMode == PlayMode.shuffle) {
|
||||||
|
finalQueue = List<Song>.from(queue)..shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
_audioService.setQueue(finalQueue, startIndex: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 单曲播放(fallback)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _playSingleSong(String songPath) async {
|
||||||
|
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
|
||||||
|
final url = WebDAVService.instance.getFileUrl(songPath);
|
||||||
|
final dbSong = await _db.getSongByPath(songPath);
|
||||||
|
final song = Song(
|
||||||
|
id: songPath,
|
||||||
|
title: dbSong?['title'] as String? ??
|
||||||
|
songPath.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
|
artist: dbSong?['artist'] as String? ?? '未知艺术家',
|
||||||
|
url: url,
|
||||||
|
);
|
||||||
|
_audioService.playSong(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 构建 Song 队列
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<List<Song>> _buildSongQueue(List<WebDAVItem> musicFiles) async {
|
||||||
|
final queue = <Song>[];
|
||||||
|
for (final item in musicFiles) {
|
||||||
|
final dbSong = await _db.getSongByPath(item.path);
|
||||||
|
final url = WebDAVService.instance.getFileUrl(item.path);
|
||||||
|
if (dbSong != null) {
|
||||||
|
queue.add(Song(
|
||||||
|
id: item.path,
|
||||||
|
title: dbSong['title'] as String? ??
|
||||||
|
item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
|
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
||||||
|
url: url,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
queue.add(Song(
|
||||||
|
id: item.path,
|
||||||
|
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
|
artist: '未知艺术家',
|
||||||
|
url: url,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 播放控制透传
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
void next() => _audioService.next();
|
||||||
|
void previous() => _audioService.previous();
|
||||||
|
void togglePlayMode() => _audioService.togglePlayMode();
|
||||||
|
PlayMode get playMode => _audioService.playMode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// lib/database/song_database.dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import '../services/audio_service.dart'; // 用于 PlayMode 枚举,但为了解耦我们传字符串
|
||||||
|
|
||||||
|
class SongDatabase {
|
||||||
|
static final SongDatabase _instance = SongDatabase._internal();
|
||||||
|
factory SongDatabase() => _instance;
|
||||||
|
SongDatabase._internal();
|
||||||
|
|
||||||
|
static Database? _database;
|
||||||
|
|
||||||
|
static bool enableLog = true;
|
||||||
|
|
||||||
|
Future<Database> get database async {
|
||||||
|
if (_database != null) return _database!;
|
||||||
|
_database = await _initDatabase();
|
||||||
|
return _database!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _initDatabase() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final path = join(dir.path, 'qingting_songs.db');
|
||||||
|
_log('📂 数据库路径: $path');
|
||||||
|
return await openDatabase(
|
||||||
|
path,
|
||||||
|
version: 4, // 升级版本号
|
||||||
|
onCreate: _onCreate,
|
||||||
|
onUpgrade: _onUpgrade,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String message) {
|
||||||
|
if (enableLog) debugPrint('📦 [DB] $message');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logQuery(String table, String operation, {Map<String, dynamic>? args}) {
|
||||||
|
if (enableLog) {
|
||||||
|
final argsStr = args != null && args.isNotEmpty ? ' $args' : '';
|
||||||
|
debugPrint('📦 [DB] $table → $operation$argsStr');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _tableExists(Database db, String tableName) async {
|
||||||
|
final result = await db.query(
|
||||||
|
'sqlite_master',
|
||||||
|
where: 'type = ? AND name = ?',
|
||||||
|
whereArgs: ['table', tableName],
|
||||||
|
);
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _columnExists(
|
||||||
|
Database db, String tableName, String columnName) async {
|
||||||
|
final result = await db.rawQuery('PRAGMA table_info($tableName)');
|
||||||
|
return result.any((col) => col['name'] == columnName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
|
_log('🆕 创建数据库 (version $version)');
|
||||||
|
await _createSongsTable(db);
|
||||||
|
await _createMetadataCacheTable(db);
|
||||||
|
await _createPlaylistsTable(db);
|
||||||
|
await _createPlaylistSongsTable(db);
|
||||||
|
await _createFavoritesTable(db);
|
||||||
|
await _createPlaybackStateTable(db);
|
||||||
|
await _createIndexes(db);
|
||||||
|
_log('✅ 数据库创建完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createSongsTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE songs (
|
||||||
|
song_key TEXT PRIMARY KEY,
|
||||||
|
remote_path TEXT,
|
||||||
|
content_hash TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
modified_time INTEGER,
|
||||||
|
etag TEXT,
|
||||||
|
title TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
album TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
artwork_path TEXT,
|
||||||
|
confidence REAL DEFAULT 0.0,
|
||||||
|
validation_count INTEGER DEFAULT 0,
|
||||||
|
first_scan INTEGER,
|
||||||
|
last_scan INTEGER,
|
||||||
|
metadata_status TEXT DEFAULT 'pending'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createMetadataCacheTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE metadata_cache (
|
||||||
|
song_key TEXT PRIMARY KEY,
|
||||||
|
cache_path TEXT,
|
||||||
|
cache_created_at INTEGER,
|
||||||
|
cache_size INTEGER,
|
||||||
|
FOREIGN KEY (song_key) REFERENCES songs(song_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: metadata_cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlaylistsTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
play_mode TEXT DEFAULT 'sequential',
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: playlists');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlaylistSongsTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE playlist_songs (
|
||||||
|
playlist_id TEXT,
|
||||||
|
song_id TEXT,
|
||||||
|
order_index INTEGER,
|
||||||
|
added_at INTEGER,
|
||||||
|
PRIMARY KEY (playlist_id, song_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: playlist_songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createFavoritesTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE favorites (
|
||||||
|
song_id TEXT PRIMARY KEY,
|
||||||
|
favorited_at INTEGER,
|
||||||
|
FOREIGN KEY (song_id) REFERENCES songs(remote_path) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: favorites');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlaybackStateTable(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE playback_state (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
queue_json TEXT,
|
||||||
|
current_index INTEGER,
|
||||||
|
play_mode TEXT DEFAULT 'sequential',
|
||||||
|
current_playlist_id TEXT,
|
||||||
|
position_ms INTEGER DEFAULT 0,
|
||||||
|
updated_at INTEGER
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_log('📋 表创建: playback_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createIndexes(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)');
|
||||||
|
await db
|
||||||
|
.execute('CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_songs_content_hash ON songs(content_hash)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id)');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_playlist_songs_order ON playlist_songs(order_index)');
|
||||||
|
_log('📋 索引创建完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 升级逻辑(版本 3 → 4)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
|
_log('⬆️ 数据库升级: $oldVersion → $newVersion');
|
||||||
|
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
if (!await _columnExists(db, 'songs', 'content_hash')) {
|
||||||
|
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
||||||
|
_log('🔧 添加列: songs.content_hash');
|
||||||
|
}
|
||||||
|
final playlistsExists = await _tableExists(db, 'playlists');
|
||||||
|
if (!playlistsExists) {
|
||||||
|
await _createPlaylistsTable(db);
|
||||||
|
await _createPlaylistSongsTable(db);
|
||||||
|
await _createFavoritesTable(db);
|
||||||
|
} else {
|
||||||
|
if (!await _columnExists(db, 'playlists', 'play_mode')) {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE playlists ADD COLUMN play_mode TEXT DEFAULT "sequential"');
|
||||||
|
_log('🔧 添加列: playlists.play_mode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _createIndexes(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
if (!await _columnExists(db, 'songs', 'content_hash')) {
|
||||||
|
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
||||||
|
_log('🔧 添加列: songs.content_hash');
|
||||||
|
}
|
||||||
|
final playlistsExists = await _tableExists(db, 'playlists');
|
||||||
|
if (!playlistsExists) {
|
||||||
|
await _createPlaylistsTable(db);
|
||||||
|
await _createPlaylistSongsTable(db);
|
||||||
|
await _createFavoritesTable(db);
|
||||||
|
} else {
|
||||||
|
if (!await _columnExists(db, 'playlists', 'play_mode')) {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE playlists ADD COLUMN play_mode TEXT DEFAULT "sequential"');
|
||||||
|
_log('🔧 添加列: playlists.play_mode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _createIndexes(db);
|
||||||
|
}
|
||||||
|
// ⭐ 升级到版本 4:添加 playback_state 表
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
final exists = await _tableExists(db, 'playback_state');
|
||||||
|
if (!exists) {
|
||||||
|
await _createPlaybackStateTable(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log('✅ 数据库升级完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 查询方法(原有)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<Map<String, dynamic>?> getSong(String songKey) async {
|
||||||
|
_logQuery('songs', 'get', args: {'song_key': songKey});
|
||||||
|
final db = await database;
|
||||||
|
final result =
|
||||||
|
await db.query('songs', where: 'song_key = ?', whereArgs: [songKey]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getSongByPath(String remotePath) async {
|
||||||
|
_logQuery('songs', 'getByPath', args: {'remote_path': remotePath});
|
||||||
|
final db = await database;
|
||||||
|
final result = await db
|
||||||
|
.query('songs', where: 'remote_path = ?', whereArgs: [remotePath]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getSongByHash(String contentHash) async {
|
||||||
|
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
|
||||||
|
final db = await database;
|
||||||
|
final result = await db
|
||||||
|
.query('songs', where: 'content_hash = ?', whereArgs: [contentHash]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getSongsByArtist(String artist,
|
||||||
|
{int limit = 20}) async {
|
||||||
|
_logQuery('songs', 'getByArtist', args: {'artist': artist, 'limit': limit});
|
||||||
|
if (artist.isEmpty) return [];
|
||||||
|
final db = await database;
|
||||||
|
return await db.query(
|
||||||
|
'songs',
|
||||||
|
where: 'artist = ?',
|
||||||
|
whereArgs: [artist],
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 播放列表 CRUD(原有)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<List<Map<String, dynamic>>> getAllPlaylists() async {
|
||||||
|
_logQuery('playlists', 'getAll');
|
||||||
|
final db = await database;
|
||||||
|
return await db.query('playlists', orderBy: 'created_at DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
||||||
|
_logQuery('playlists', 'get', args: {'id': id});
|
||||||
|
final db = await database;
|
||||||
|
final result =
|
||||||
|
await db.query('playlists', where: 'id = ?', whereArgs: [id]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertPlaylist(Map<String, dynamic> playlist) async {
|
||||||
|
_logQuery('playlists', 'insert', args: {'name': playlist['name']});
|
||||||
|
final db = await database;
|
||||||
|
await db.insert('playlists', playlist,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updatePlaylist(String id, Map<String, dynamic> updates) async {
|
||||||
|
_logQuery('playlists', 'update',
|
||||||
|
args: {'id': id, 'updates': updates.keys.join(',')});
|
||||||
|
final db = await database;
|
||||||
|
await db.update('playlists', updates, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePlaylist(String id) async {
|
||||||
|
_logQuery('playlists', 'delete', args: {'id': id});
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('playlists', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 播放列表歌曲(原有)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<List<Map<String, dynamic>>> getPlaylistSongs(String playlistId) async {
|
||||||
|
_logQuery('playlist_songs', 'get', args: {'playlist_id': playlistId});
|
||||||
|
final db = await database;
|
||||||
|
return await db.query(
|
||||||
|
'playlist_songs',
|
||||||
|
where: 'playlist_id = ?',
|
||||||
|
whereArgs: [playlistId],
|
||||||
|
orderBy: 'order_index ASC',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSongToPlaylist(String playlistId, String songId,
|
||||||
|
{int? orderIndex}) async {
|
||||||
|
_logQuery('playlist_songs', 'add',
|
||||||
|
args: {'playlist_id': playlistId, 'song_id': songId});
|
||||||
|
final db = await database;
|
||||||
|
int finalOrder = orderIndex ?? 0;
|
||||||
|
if (orderIndex == null) {
|
||||||
|
final existing = await db.query(
|
||||||
|
'playlist_songs',
|
||||||
|
where: 'playlist_id = ?',
|
||||||
|
whereArgs: [playlistId],
|
||||||
|
orderBy: 'order_index DESC',
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
finalOrder =
|
||||||
|
existing.isNotEmpty ? (existing.first['order_index'] as int) + 1 : 0;
|
||||||
|
}
|
||||||
|
await db.insert(
|
||||||
|
'playlist_songs',
|
||||||
|
{
|
||||||
|
'playlist_id': playlistId,
|
||||||
|
'song_id': songId,
|
||||||
|
'order_index': finalOrder,
|
||||||
|
'added_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSongFromPlaylist(String playlistId, String songId) async {
|
||||||
|
_logQuery('playlist_songs', 'remove',
|
||||||
|
args: {'playlist_id': playlistId, 'song_id': songId});
|
||||||
|
final db = await database;
|
||||||
|
await db.delete(
|
||||||
|
'playlist_songs',
|
||||||
|
where: 'playlist_id = ? AND song_id = ?',
|
||||||
|
whereArgs: [playlistId, songId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPlaylist(String playlistId) async {
|
||||||
|
_logQuery('playlist_songs', 'clear', args: {'playlist_id': playlistId});
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('playlist_songs',
|
||||||
|
where: 'playlist_id = ?', whereArgs: [playlistId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 收藏(原有)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<List<Map<String, dynamic>>> getFavorites() async {
|
||||||
|
_logQuery('favorites', 'getAll');
|
||||||
|
final db = await database;
|
||||||
|
return await db.query('favorites', orderBy: 'favorited_at DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isFavorite(String songId) async {
|
||||||
|
_logQuery('favorites', 'isFavorite', args: {'song_id': songId});
|
||||||
|
final db = await database;
|
||||||
|
final result =
|
||||||
|
await db.query('favorites', where: 'song_id = ?', whereArgs: [songId]);
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addFavorite(String songId) async {
|
||||||
|
_logQuery('favorites', 'add', args: {'song_id': songId});
|
||||||
|
final db = await database;
|
||||||
|
await db.insert(
|
||||||
|
'favorites',
|
||||||
|
{
|
||||||
|
'song_id': songId,
|
||||||
|
'favorited_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFavorite(String songId) async {
|
||||||
|
_logQuery('favorites', 'remove', args: {'song_id': songId});
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('favorites', where: 'song_id = ?', whereArgs: [songId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleFavorite(String songId) async {
|
||||||
|
if (await isFavorite(songId)) {
|
||||||
|
await removeFavorite(songId);
|
||||||
|
} else {
|
||||||
|
await addFavorite(songId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 原有方法(歌曲 CRUD)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Future<void> insertSong(Map<String, dynamic> song) async {
|
||||||
|
_logQuery('songs', 'insert', args: {'title': song['title']});
|
||||||
|
final db = await database;
|
||||||
|
await db.insert('songs', song,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateSong(String songKey, Map<String, dynamic> updates) async {
|
||||||
|
_logQuery('songs', 'update',
|
||||||
|
args: {'song_key': songKey, 'fields': updates.keys.join(',')});
|
||||||
|
final db = await database;
|
||||||
|
await db
|
||||||
|
.update('songs', updates, where: 'song_key = ?', whereArgs: [songKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteSong(String songKey) async {
|
||||||
|
_logQuery('songs', 'delete', args: {'song_key': songKey});
|
||||||
|
final db = await database;
|
||||||
|
await db.delete('songs', where: 'song_key = ?', whereArgs: [songKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCache(String songKey) async {
|
||||||
|
_logQuery('metadata_cache', 'delete', args: {'song_key': songKey});
|
||||||
|
final db = await database;
|
||||||
|
await db
|
||||||
|
.delete('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertCache(Map<String, dynamic> cache) async {
|
||||||
|
_logQuery('metadata_cache', 'insert',
|
||||||
|
args: {'song_key': cache['song_key']});
|
||||||
|
final db = await database;
|
||||||
|
await db.insert('metadata_cache', cache,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getCache(String songKey) async {
|
||||||
|
_logQuery('metadata_cache', 'get', args: {'song_key': songKey});
|
||||||
|
final db = await database;
|
||||||
|
final result = await db
|
||||||
|
.query('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 新增:播放状态持久化
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// 保存播放状态
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
required List<Map<String, dynamic>> queueJson, // 预先序列化的列表
|
||||||
|
required int currentIndex,
|
||||||
|
required String playMode,
|
||||||
|
String? currentPlaylistId,
|
||||||
|
required int positionMs,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.insert(
|
||||||
|
'playback_state',
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'queue_json': jsonEncode(queueJson),
|
||||||
|
'current_index': currentIndex,
|
||||||
|
'play_mode': playMode,
|
||||||
|
'current_playlist_id': currentPlaylistId,
|
||||||
|
'position_ms': positionMs,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
_log('💾 播放状态已保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取播放状态
|
||||||
|
Future<Map<String, dynamic>?> getPlaybackState() async {
|
||||||
|
final db = await database;
|
||||||
|
final result =
|
||||||
|
await db.query('playback_state', where: 'id = ?', whereArgs: [1]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
_log('🔒 关闭数据库连接');
|
||||||
|
final db = await database;
|
||||||
|
await db.close();
|
||||||
|
_database = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
-86
@@ -1,9 +1,11 @@
|
|||||||
|
// lib/main.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
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';
|
||||||
@@ -14,25 +16,20 @@ 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();
|
||||||
|
|
||||||
// ⭐ 防止 Hot Restart / 热重载时重复初始化
|
|
||||||
bool _appInitialized = false;
|
bool _appInitialized = false;
|
||||||
|
|
||||||
// ⭐ 全局缓存 Handler
|
|
||||||
AudioPlayerHandler? _audioHandler;
|
AudioPlayerHandler? _audioHandler;
|
||||||
|
|
||||||
// ⭐ 全局缓存 LifecycleManager
|
|
||||||
final AppLifecycleManager lifecycleManager = AppLifecycleManager();
|
final AppLifecycleManager lifecycleManager = AppLifecycleManager();
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
// ⭐ P0:防止重复初始化
|
|
||||||
if (_appInitialized) {
|
if (_appInitialized) {
|
||||||
debugPrint('⏱️ main 已初始化,跳过重复执行');
|
debugPrint('⏱️ main 已初始化,跳过重复执行');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_appInitialized = true;
|
_appInitialized = true;
|
||||||
|
|
||||||
stopwatch.start();
|
stopwatch.start();
|
||||||
@@ -41,42 +38,71 @@ void main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
debugPrint('⏱️ T0.5 ensureInitialized: ${stopwatch.elapsedMilliseconds}ms');
|
debugPrint('⏱️ T0.5 ensureInitialized: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
// ⭐ MediaKit 在 runApp 前初始化(官方要求)
|
debugPrint('⏱️ T0.55 before MediaKit.ensureInitialized');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
debugPrint('⏱️ T0.6 MediaKit 初始化完成');
|
debugPrint('⏱️ T0.6 MediaKit.ensureInitialized returned');
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ MediaKit 初始化失败: $e');
|
debugPrint('❌ MediaKit.ensureInitialized failed: $e');
|
||||||
|
debugPrint('$st');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 创建 Handler(AudioService 稍后初始化)
|
debugPrint('⏱️ T0.61 before AudioPlayerHandler');
|
||||||
|
|
||||||
|
try {
|
||||||
_audioHandler = AudioPlayerHandler();
|
_audioHandler = AudioPlayerHandler();
|
||||||
|
debugPrint('⏱️ T0.62 AudioPlayerHandler created');
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('❌ AudioPlayerHandler failed: $e');
|
||||||
|
debugPrint('$st');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('⏱️ T0.63 before setOnSongChanged');
|
||||||
|
|
||||||
// ⭐ 注册切歌回调:当歌曲切换时,立即更新通知
|
|
||||||
AudioService().setOnSongChanged((song) {
|
AudioService().setOnSongChanged((song) {
|
||||||
|
debugPrint(
|
||||||
|
'📢 [main] song.artwork is '
|
||||||
|
'${song.artwork != null ? 'not null' : 'null'}',
|
||||||
|
);
|
||||||
|
|
||||||
_audioHandler!.updateNotification(
|
_audioHandler!.updateNotification(
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
|
artwork: song.artwork,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
debugPrint('⏱️ T0.7 before runApp');
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => AudioService()),
|
ChangeNotifierProvider(create: (_) => AudioService()),
|
||||||
ChangeNotifierProvider(create: (_) => AppLifecycleService()),
|
ChangeNotifierProvider(create: (_) => AppLifecycleService()),
|
||||||
// ⭐ 生命周期管理器
|
|
||||||
ChangeNotifierProvider<AppLifecycleManager>(
|
ChangeNotifierProvider<AppLifecycleManager>(
|
||||||
create: (_) => lifecycleManager,
|
create: (_) => lifecycleManager,
|
||||||
),
|
),
|
||||||
Provider<AudioPlayerHandler>(create: (_) => _audioHandler!),
|
Provider<AudioPlayerHandler>(
|
||||||
|
create: (_) => _audioHandler!,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const QTPlayerApp(),
|
child: const QTPlayerApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms');
|
debugPrint(
|
||||||
|
'⏱️ T0.8 runApp 完成: '
|
||||||
|
'${stopwatch.elapsedMilliseconds}ms',
|
||||||
|
);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
debugPrint('⏱️ T1 postFrame');
|
||||||
|
AudioService().restorePlaybackState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -107,7 +133,13 @@ class RouteManager extends ChangeNotifier {
|
|||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
class MiniPlayerNavigatorObserver extends NavigatorObserver {
|
class MiniPlayerNavigatorObserver extends NavigatorObserver {
|
||||||
void _updateRoute(Route? route) {
|
void _updateRoute(Route? route) {
|
||||||
final name = route?.settings.name ?? '/';
|
if (route == null) return;
|
||||||
|
|
||||||
|
if (route is PopupRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = route.settings.name ?? '/';
|
||||||
RouteManager().updateRoute(name);
|
RouteManager().updateRoute(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +150,20 @@ class MiniPlayerNavigatorObserver extends NavigatorObserver {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPop(Route route, Route? previousRoute) {
|
void didPop(Route route, Route? previousRoute) {
|
||||||
_updateRoute(previousRoute);
|
if (previousRoute != null && previousRoute is! PopupRoute) {
|
||||||
|
final name = previousRoute.settings.name ?? '/';
|
||||||
|
RouteManager().updateRoute(name);
|
||||||
|
} else if (previousRoute == null) {
|
||||||
|
RouteManager().updateRoute('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didReplace({Route? newRoute, Route? oldRoute}) {
|
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||||
_updateRoute(newRoute);
|
if (newRoute != null && newRoute is! PopupRoute) {
|
||||||
|
final name = newRoute.settings.name ?? '/';
|
||||||
|
RouteManager().updateRoute(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,71 +178,48 @@ class QTPlayerApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _QTPlayerAppState extends State<QTPlayerApp> {
|
class _QTPlayerAppState extends State<QTPlayerApp> {
|
||||||
// ⭐ build 计数器(诊断用)
|
|
||||||
static int _buildCount = 0;
|
static int _buildCount = 0;
|
||||||
|
|
||||||
bool _isInitializing = false;
|
bool _isInitializing = false;
|
||||||
|
|
||||||
// ⭐ 保存生命周期监听器引用
|
|
||||||
void Function(AppLifecycleStatus)? _lifecycleListener;
|
void Function(AppLifecycleStatus)? _lifecycleListener;
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 生命周期
|
|
||||||
// ============================================================
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
debugPrint('⏱️ T1.5 initState: ${stopwatch.elapsedMilliseconds}ms');
|
debugPrint('⏱️ T1.5 initState: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
debugPrint('⏱️ T2 首帧回调: ${stopwatch.elapsedMilliseconds}ms');
|
debugPrint('⏱️ T2 首帧回调: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
// ⭐ 1. 初始化生命周期管理器
|
|
||||||
lifecycleManager.init();
|
lifecycleManager.init();
|
||||||
|
|
||||||
// ⭐ 2. 绑定生命周期到播放器
|
|
||||||
_bindLifecycleToPlayback();
|
_bindLifecycleToPlayback();
|
||||||
|
|
||||||
// ⭐ 3. 后台初始化
|
|
||||||
_startBackgroundInitialization();
|
_startBackgroundInitialization();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// ⭐ 移除生命周期监听器
|
|
||||||
if (_lifecycleListener != null) {
|
if (_lifecycleListener != null) {
|
||||||
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
|
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 生命周期 → 播放器绑定
|
|
||||||
// ============================================================
|
|
||||||
void _bindLifecycleToPlayback() {
|
void _bindLifecycleToPlayback() {
|
||||||
if (_lifecycleListener != null) {
|
if (_lifecycleListener != null) {
|
||||||
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
|
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
|
||||||
}
|
}
|
||||||
|
|
||||||
_lifecycleListener = (status) {
|
_lifecycleListener = (status) {
|
||||||
final audioService = context.read<AudioService>();
|
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case AppLifecycleStatus.paused:
|
case AppLifecycleStatus.paused:
|
||||||
case AppLifecycleStatus.inactive:
|
case AppLifecycleStatus.inactive:
|
||||||
debugPrint('🎵 App 进入后台,播放继续');
|
debugPrint('🎵 App 进入后台,播放继续');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AppLifecycleStatus.resumed:
|
case AppLifecycleStatus.resumed:
|
||||||
debugPrint('🎵 App 回到前台');
|
debugPrint('🎵 App 回到前台');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AppLifecycleStatus.backgroundIdle:
|
case AppLifecycleStatus.backgroundIdle:
|
||||||
debugPrint('🎵 长后台唤醒,检查播放状态');
|
debugPrint('🎵 长后台唤醒,检查播放状态');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -211,9 +228,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
lifecycleManager.addListenerToManager(_lifecycleListener!);
|
lifecycleManager.addListenerToManager(_lifecycleListener!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 后台初始化(精细时间切片)
|
|
||||||
// ============================================================
|
|
||||||
Future<void> _startBackgroundInitialization() async {
|
Future<void> _startBackgroundInitialization() async {
|
||||||
if (_isInitializing) return;
|
if (_isInitializing) return;
|
||||||
_isInitializing = true;
|
_isInitializing = true;
|
||||||
@@ -221,7 +235,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
final tStart = DateTime.now();
|
final tStart = DateTime.now();
|
||||||
debugPrint('⏱️ T3 后台初始化开始');
|
debugPrint('⏱️ T3 后台初始化开始');
|
||||||
|
|
||||||
// ----- 1. PlaybackService -----
|
|
||||||
final t1 = DateTime.now();
|
final t1 = DateTime.now();
|
||||||
try {
|
try {
|
||||||
PlaybackService().init();
|
PlaybackService().init();
|
||||||
@@ -230,7 +243,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
debugPrint('❌ PlaybackService 失败: $e');
|
debugPrint('❌ PlaybackService 失败: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 2. WebDAV(只加载凭证) -----
|
|
||||||
final t2 = DateTime.now();
|
final t2 = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final hasCred = await WebDAVService.instance.loadCredentials();
|
final hasCred = await WebDAVService.instance.loadCredentials();
|
||||||
@@ -239,10 +251,8 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
debugPrint('❌ WebDAV 凭证加载失败: $e');
|
debugPrint('❌ WebDAV 凭证加载失败: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 3. ⭐ AudioService(后台播放核心) -----
|
|
||||||
await _initAudioService();
|
await _initAudioService();
|
||||||
|
|
||||||
// ----- 4. 通知权限 -----
|
|
||||||
final t3 = DateTime.now();
|
final t3 = DateTime.now();
|
||||||
try {
|
try {
|
||||||
if (await Permission.notification.isDenied) {
|
if (await Permission.notification.isDenied) {
|
||||||
@@ -259,9 +269,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
debugPrint('⏱️ T4 全部初始化完成: ${tEnd.difference(tStart).inMilliseconds}ms');
|
debugPrint('⏱️ T4 全部初始化完成: ${tEnd.difference(tStart).inMilliseconds}ms');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// ⭐ AudioService 初始化(后台播放核心)
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<void> _initAudioService() async {
|
Future<void> _initAudioService() async {
|
||||||
try {
|
try {
|
||||||
await audio_service.AudioService.init(
|
await audio_service.AudioService.init(
|
||||||
@@ -279,43 +286,93 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 辅助方法
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
String _elapsedMs(DateTime start) {
|
String _elapsedMs(DateTime start) {
|
||||||
return '${DateTime.now().difference(start).inMilliseconds}';
|
return '${DateTime.now().difference(start).inMilliseconds}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// Build
|
// ⭐ 路由生成(播放页 & 播放列表页底部滑入)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
|
||||||
|
// ⭐ 播放页:底部滑入
|
||||||
|
if (settings.name == '/player') {
|
||||||
|
return PageRouteBuilder(
|
||||||
|
settings: settings,
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
|
return const PlayerPage();
|
||||||
|
},
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curvedAnimation = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
const begin = Offset(0.0, 1.0);
|
||||||
|
const end = Offset.zero;
|
||||||
|
final tween = Tween(begin: begin, end: end);
|
||||||
|
final offsetAnimation = tween.animate(curvedAnimation);
|
||||||
|
return SlideTransition(
|
||||||
|
position: offsetAnimation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transitionDuration: const Duration(milliseconds: 250),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ 播放列表页:底部滑入(与播放页一致)
|
||||||
|
if (settings.name == '/playlist') {
|
||||||
|
return PageRouteBuilder(
|
||||||
|
settings: settings,
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
|
return const PlaylistPage();
|
||||||
|
},
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curvedAnimation = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
const begin = Offset(0.0, 1.0);
|
||||||
|
const end = Offset.zero;
|
||||||
|
final tween = Tween(begin: begin, end: end);
|
||||||
|
final offsetAnimation = tween.animate(curvedAnimation);
|
||||||
|
return SlideTransition(
|
||||||
|
position: offsetAnimation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transitionDuration: const Duration(milliseconds: 250),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他页面使用默认
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) {
|
||||||
|
switch (settings.name) {
|
||||||
|
case '/':
|
||||||
|
return const HomePage();
|
||||||
|
default:
|
||||||
|
return const HomePage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_buildCount++;
|
_buildCount++;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
'🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
// 监听歌曲变化,更新通知栏
|
// ⭐ 使用 AnnotatedRegion 包裹 MaterialApp,统一控制系统栏样式
|
||||||
// main.dart 中 build 方法里的这部分
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
final audioService = context.watch<AudioService>();
|
value: SystemUiOverlayStyle(
|
||||||
final handler = context.read<AudioPlayerHandler>();
|
statusBarColor: Colors.transparent,
|
||||||
final song = audioService.currentSong;
|
statusBarIconBrightness: Brightness.light,
|
||||||
|
systemNavigationBarColor: const Color(0xFF1A1F1E),
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
if (song != null) {
|
),
|
||||||
// ⭐ song 不为空时才调用
|
child: MaterialApp(
|
||||||
handler.updateNotification(
|
|
||||||
id: song.id,
|
|
||||||
title: song.title,
|
|
||||||
artist: song.artist,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 清空通知
|
|
||||||
handler.mediaItem.add(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return MaterialApp(
|
|
||||||
title: '清听',
|
title: '清听',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
@@ -330,11 +387,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
),
|
),
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
navigatorObservers: [MiniPlayerNavigatorObserver()],
|
navigatorObservers: [MiniPlayerNavigatorObserver()],
|
||||||
routes: {
|
onGenerateRoute: _onGenerateRoute,
|
||||||
'/': (context) => const HomePage(),
|
|
||||||
'/player': (context) => const PlayerPage(),
|
|
||||||
'/playlist': (context) => const PlaylistPage(),
|
|
||||||
},
|
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -343,18 +396,42 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: RouteManager(),
|
animation: RouteManager(),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (!RouteManager().showMiniPlayer) {
|
final routeManager = RouteManager();
|
||||||
return const SizedBox.shrink();
|
final shouldShow = routeManager.currentRoute != '/player' &&
|
||||||
}
|
routeManager.currentRoute != '/playlist';
|
||||||
return const Align(
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: shouldShow
|
||||||
|
? const Align(
|
||||||
|
key: ValueKey('mini_player_visible'),
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: GlobalMiniPlayer(),
|
child: GlobalMiniPlayer(),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(
|
||||||
|
key: ValueKey('mini_player_hidden'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,57 @@
|
|||||||
// lib/metadata/file_provider.dart
|
// lib/metadata/file_provider.dart
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart'; // ⭐ 添加
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../services/webdav_service.dart';
|
||||||
|
|
||||||
abstract class FileProvider {
|
abstract class FileProvider {
|
||||||
/// 获取文件,如果是远程文件则先下载到本地缓存
|
|
||||||
Future<File?> getFile(String url);
|
Future<File?> getFile(String url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WebDAVFileProvider implements FileProvider {
|
||||||
|
final Dio _dio = Dio();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<File?> getFile(String url) async {
|
||||||
|
try {
|
||||||
|
// 1. 获取缓存目录
|
||||||
|
final cacheDir = await getTemporaryDirectory();
|
||||||
|
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
||||||
|
final cacheFile = File(cachePath);
|
||||||
|
|
||||||
|
// 2. 如果缓存存在且未过期,直接返回
|
||||||
|
if (await cacheFile.exists()) {
|
||||||
|
final stat = await cacheFile.stat();
|
||||||
|
// 缓存 1 小时内有效(86400 秒)
|
||||||
|
if (DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
||||||
|
return cacheFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取认证头
|
||||||
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
|
if (headers.isEmpty) return null;
|
||||||
|
|
||||||
|
// 4. 只下载前 1MB
|
||||||
|
await _dio.download(
|
||||||
|
url,
|
||||||
|
cachePath,
|
||||||
|
options: Options(
|
||||||
|
headers: headers,
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
sendTimeout: const Duration(seconds: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return cacheFile;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class LocalFileProvider implements FileProvider {
|
class LocalFileProvider implements FileProvider {
|
||||||
@override
|
@override
|
||||||
Future<File?> getFile(String url) async {
|
Future<File?> getFile(String url) async {
|
||||||
@@ -16,13 +62,3 @@ class LocalFileProvider implements FileProvider {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebDAVFileProvider implements FileProvider {
|
|
||||||
@override
|
|
||||||
Future<File?> getFile(String url) async {
|
|
||||||
// 🔴 第一阶段:先不实现下载,返回 null
|
|
||||||
// 这样 WebDAV 文件会 fallback 到文件名
|
|
||||||
// 等核心播放逻辑稳定后,再实现真正的下载缓存
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class MetadataCache {
|
|||||||
_cache[fileId] = metadata;
|
_cache[fileId] = metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String fileId) async {
|
||||||
|
_cache.remove(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getHistory(String artist,
|
Future<List<Map<String, dynamic>>> getHistory(String artist,
|
||||||
{int limit = 10}) async {
|
{int limit = 10}) async {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ class RawMetadata {
|
|||||||
final String title;
|
final String title;
|
||||||
final String artist;
|
final String artist;
|
||||||
final String album;
|
final String album;
|
||||||
final List<String> genres; // ⬅️ 改为 List<String>
|
final List<String> genres;
|
||||||
final List<String> performers; // ⬅️ 新增
|
final List<String> performers;
|
||||||
final int year;
|
final int year;
|
||||||
final int trackNumber;
|
final int trackNumber;
|
||||||
final int trackTotal;
|
final int trackTotal;
|
||||||
@@ -83,13 +83,26 @@ class NormalizedMetadata {
|
|||||||
bool get isNotEmpty => !isEmpty;
|
bool get isNotEmpty => !isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 候选元数据
|
||||||
|
class CandidateMetadata {
|
||||||
|
final NormalizedMetadata metadata;
|
||||||
|
final double confidence;
|
||||||
|
final Map<String, double> evidence;
|
||||||
|
|
||||||
|
const CandidateMetadata({
|
||||||
|
required this.metadata,
|
||||||
|
required this.confidence,
|
||||||
|
this.evidence = const {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 最终确认的元数据
|
/// 最终确认的元数据
|
||||||
class FinalMetadata {
|
class FinalMetadata {
|
||||||
final String title;
|
final String title;
|
||||||
final String artist;
|
final String artist;
|
||||||
final String album;
|
final String album;
|
||||||
final String genre; // ⬅️ 简化为单个流派(取第一个)
|
final String genre;
|
||||||
final List<String> genres; // ⬅️ 保留完整列表
|
final List<String> genres;
|
||||||
final List<String> performers;
|
final List<String> performers;
|
||||||
final int year;
|
final int year;
|
||||||
final int trackNumber;
|
final int trackNumber;
|
||||||
@@ -128,16 +141,3 @@ class FinalMetadata {
|
|||||||
|
|
||||||
bool get isNotEmpty => !isEmpty;
|
bool get isNotEmpty => !isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 候选元数据
|
|
||||||
class CandidateMetadata {
|
|
||||||
final NormalizedMetadata metadata;
|
|
||||||
final double confidence;
|
|
||||||
final Map<String, double> evidence;
|
|
||||||
|
|
||||||
const CandidateMetadata({
|
|
||||||
required this.metadata,
|
|
||||||
required this.confidence,
|
|
||||||
this.evidence = const {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,20 +4,13 @@ import 'metadata_model.dart';
|
|||||||
class MetadataNormalizer {
|
class MetadataNormalizer {
|
||||||
NormalizedMetadata normalize(RawMetadata raw,
|
NormalizedMetadata normalize(RawMetadata raw,
|
||||||
{String fileName = '', String filePath = ''}) {
|
{String fileName = '', String filePath = ''}) {
|
||||||
// 1. 修剪空格
|
|
||||||
final title = raw.title.trim();
|
final title = raw.title.trim();
|
||||||
final artist = raw.artist.trim();
|
final artist = raw.artist.trim();
|
||||||
final album = raw.album.trim();
|
|
||||||
|
|
||||||
// 2. 如果 title 为空,从文件名推断
|
|
||||||
final finalTitle =
|
final finalTitle =
|
||||||
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
|
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
|
||||||
|
|
||||||
// 3. 如果 artist 为空,从路径推断或使用默认值
|
|
||||||
final finalArtist =
|
final finalArtist =
|
||||||
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
|
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
|
||||||
|
|
||||||
// 4. 如果 performers 不为空且 artist 为空,用 performers 的第一个
|
|
||||||
final finalArtist2 = finalArtist.isNotEmpty
|
final finalArtist2 = finalArtist.isNotEmpty
|
||||||
? finalArtist
|
? finalArtist
|
||||||
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
|
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
|
||||||
@@ -25,7 +18,7 @@ class MetadataNormalizer {
|
|||||||
return NormalizedMetadata(
|
return NormalizedMetadata(
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
artist: finalArtist2,
|
artist: finalArtist2,
|
||||||
album: album,
|
album: raw.album,
|
||||||
genres: raw.genres,
|
genres: raw.genres,
|
||||||
performers: raw.performers,
|
performers: raw.performers,
|
||||||
year: raw.year,
|
year: raw.year,
|
||||||
@@ -40,17 +33,16 @@ class MetadataNormalizer {
|
|||||||
|
|
||||||
String _inferTitleFromFileName(String fileName) {
|
String _inferTitleFromFileName(String fileName) {
|
||||||
final dotIndex = fileName.lastIndexOf('.');
|
final dotIndex = fileName.lastIndexOf('.');
|
||||||
if (dotIndex > 0) {
|
return dotIndex > 0
|
||||||
return fileName.substring(0, dotIndex).trim();
|
? fileName.substring(0, dotIndex).trim()
|
||||||
}
|
: fileName.trim();
|
||||||
return fileName.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _inferArtistFromPath(String filePath) {
|
String _inferArtistFromPath(String filePath) {
|
||||||
final parts = filePath.split(RegExp(r'[/\\]'));
|
final parts = filePath.split(RegExp(r'[/\\]'));
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final candidate = parts[parts.length - 2].trim();
|
final candidate = parts[parts.length - 2].trim();
|
||||||
if (candidate.isNotEmpty && !candidate.contains(' ')) {
|
if (candidate.isNotEmpty && !candidate.startsWith('http')) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +51,7 @@ class MetadataNormalizer {
|
|||||||
|
|
||||||
CandidateMetadata evaluate(
|
CandidateMetadata evaluate(
|
||||||
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
|
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
|
||||||
|
// 第一版:直接接受,置信度 0.8
|
||||||
return CandidateMetadata(
|
return CandidateMetadata(
|
||||||
metadata: normalized,
|
metadata: normalized,
|
||||||
confidence: 0.8,
|
confidence: 0.8,
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
// lib/metadata/metadata_reader.dart
|
// lib/metadata/metadata_reader.dart
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr;
|
import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr;
|
||||||
import 'metadata_model.dart';
|
import 'metadata_model.dart';
|
||||||
|
|
||||||
class MetadataReader {
|
class MetadataReader {
|
||||||
Future<RawMetadata> readRawMetadata(File file) async {
|
Future<RawMetadata> readRawMetadata(File file) async {
|
||||||
try {
|
try {
|
||||||
final meta = amr.readMetadata(file, getImage: false);
|
final meta = amr.readMetadata(file, getImage: true);
|
||||||
|
|
||||||
|
debugPrint('📢 [MetadataReader] pictures count: ${meta.pictures.length}');
|
||||||
|
|
||||||
|
Uint8List? artwork;
|
||||||
|
if (meta.pictures.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final pic = meta.pictures.first;
|
||||||
|
// 尝试获取图片数据,兼容不同字段名
|
||||||
|
artwork = (pic as dynamic).bytes ?? (pic as dynamic).data;
|
||||||
|
if (artwork != null) {
|
||||||
|
debugPrint(
|
||||||
|
'📢 [MetadataReader] artwork extracted: ${artwork.length} bytes');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [MetadataReader] artwork extraction failed: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('📢 [MetadataReader] no artwork found');
|
||||||
|
}
|
||||||
|
|
||||||
return RawMetadata(
|
return RawMetadata(
|
||||||
title: meta.title ?? '',
|
title: meta.title ?? '',
|
||||||
artist: meta.artist ?? '',
|
artist: meta.artist ?? '',
|
||||||
album: meta.album ?? '',
|
album: meta.album ?? '',
|
||||||
// ⭐ genres 和 performers 是非空 List<String>,直接使用
|
|
||||||
genres: meta.genres,
|
genres: meta.genres,
|
||||||
performers: meta.performers,
|
performers: meta.performers,
|
||||||
year: _toInt(meta.year),
|
year: _toInt(meta.year),
|
||||||
@@ -21,18 +41,18 @@ class MetadataReader {
|
|||||||
discNumber: _toInt(meta.discNumber),
|
discNumber: _toInt(meta.discNumber),
|
||||||
totalDisc: _toInt(meta.totalDisc),
|
totalDisc: _toInt(meta.totalDisc),
|
||||||
duration: meta.duration,
|
duration: meta.duration,
|
||||||
|
artwork: artwork,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [MetadataReader] read failed: $e');
|
||||||
return const RawMetadata();
|
return const RawMetadata();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 安全转换为 int,处理 Object 类型
|
|
||||||
int _toInt(dynamic value) {
|
int _toInt(dynamic value) {
|
||||||
if (value == null) return 0;
|
if (value == null) return 0;
|
||||||
if (value is int) return value;
|
if (value is int) return value;
|
||||||
if (value is String) return int.tryParse(value) ?? 0;
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
// 有些字段可能是 double 或 num
|
|
||||||
if (value is num) return value.toInt();
|
if (value is num) return value.toInt();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,161 @@
|
|||||||
// lib/metadata/metadata_service.dart
|
// lib/metadata/metadata_service.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
import 'metadata_model.dart';
|
import 'metadata_model.dart';
|
||||||
|
import 'metadata_reader.dart';
|
||||||
|
import 'metadata_normalizer.dart';
|
||||||
import 'metadata_cache.dart';
|
import 'metadata_cache.dart';
|
||||||
|
import 'file_provider.dart';
|
||||||
|
import '../utils/artwork_helper.dart';
|
||||||
|
|
||||||
class MetadataService {
|
class MetadataService {
|
||||||
static final MetadataService _instance = MetadataService._internal();
|
static final MetadataService _instance = MetadataService._internal();
|
||||||
factory MetadataService() => _instance;
|
factory MetadataService() => _instance;
|
||||||
MetadataService._internal();
|
MetadataService._internal();
|
||||||
|
|
||||||
final MetadataCache _cache = MetadataCache();
|
final MetadataReader _reader = MetadataReader();
|
||||||
|
final MetadataNormalizer _normalizer = MetadataNormalizer();
|
||||||
|
final MetadataCache _memoryCache = MetadataCache();
|
||||||
|
final SongDatabase _db = SongDatabase();
|
||||||
|
|
||||||
|
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
||||||
|
final raw = '$url|$fileSize|$modifiedTime';
|
||||||
|
return raw.hashCode.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
FileProvider _getProvider(String url) {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return WebDAVFileProvider();
|
||||||
|
}
|
||||||
|
return LocalFileProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 公开给 AudioService 使用(用于清除缓存时获取文件)
|
||||||
|
FileProvider getProviderForUrl(String url) {
|
||||||
|
return _getProvider(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除内存缓存
|
||||||
|
Future<void> clearCache(String fileId) async {
|
||||||
|
await _memoryCache.remove(fileId);
|
||||||
|
debugPrint('🗑️ [MetadataService] memory cache cleared: $fileId');
|
||||||
|
}
|
||||||
|
|
||||||
/// 临时版本:不进行实际文件读取,只返回基于文件名的 fallback
|
|
||||||
Future<FinalMetadata> getMetadata({
|
Future<FinalMetadata> getMetadata({
|
||||||
required String url,
|
required String url,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
required String fileId,
|
required String fileId,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
// 1. 检查缓存(如果有)
|
// 1. 内存缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
final cached = await _cache.get(fileId);
|
final cached = await _memoryCache.get(fileId);
|
||||||
if (cached != null && cached.isNotEmpty) {
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
debugPrint('📦 [MetadataService] memory hit: ${cached.title}');
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 暂时不读取文件,直接 fallback
|
// 2. 获取文件
|
||||||
final fallback = _fallbackFromFileName(fileName);
|
final provider = _getProvider(url);
|
||||||
|
final file = await provider.getFile(url);
|
||||||
|
|
||||||
// 3. 缓存结果(即使 fallback 也缓存,避免频繁调用)
|
if (file == null || !await file.exists()) {
|
||||||
if (fallback.isNotEmpty) {
|
debugPrint('⚠️ [MetadataService] file not available, using fallback');
|
||||||
await _cache.put(fileId, fallback);
|
return _fallbackFromFileName(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
final stat = await file.stat();
|
||||||
|
final songKey =
|
||||||
|
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
// 3. SQLite 查询(高置信度直接使用)
|
||||||
|
if (!forceRefresh) {
|
||||||
|
final dbSong = await _db.getSong(songKey);
|
||||||
|
if (dbSong != null) {
|
||||||
|
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
if (confidence >= 0.6) {
|
||||||
|
debugPrint(
|
||||||
|
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
|
||||||
|
|
||||||
|
// 读取封面图
|
||||||
|
final artworkPath = dbSong['artwork_path'] as String?;
|
||||||
|
Uint8List? artwork;
|
||||||
|
if (artworkPath != null && artworkPath.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final artFile = File(artworkPath);
|
||||||
|
if (await artFile.exists()) {
|
||||||
|
artwork = await artFile.readAsBytes();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final metadata = FinalMetadata(
|
||||||
|
title: dbSong['title'] as String? ?? '',
|
||||||
|
artist: dbSong['artist'] as String? ?? '',
|
||||||
|
album: dbSong['album'] as String? ?? '',
|
||||||
|
genre: dbSong['genre'] as String? ?? '',
|
||||||
|
artwork: artwork,
|
||||||
|
source: 'database',
|
||||||
|
);
|
||||||
|
await _memoryCache.put(fileId, metadata);
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 读取原始 metadata
|
||||||
|
final raw = await _reader.readRawMetadata(file);
|
||||||
|
|
||||||
|
// 5. 标准化
|
||||||
|
final normalized = _normalizer.normalize(
|
||||||
|
raw,
|
||||||
|
fileName: fileName,
|
||||||
|
filePath: url,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 评估
|
||||||
|
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
|
||||||
|
final candidate = _normalizer.evaluate(normalized, history);
|
||||||
|
final finalMetadata = _normalizer.decide(candidate);
|
||||||
|
debugPrint(
|
||||||
|
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
|
||||||
|
|
||||||
|
// 7. 保存封面图到本地
|
||||||
|
String? artworkPath;
|
||||||
|
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
|
||||||
|
artworkPath =
|
||||||
|
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 保存到 SQLite
|
||||||
|
await _db.insertSong({
|
||||||
|
'song_key': songKey,
|
||||||
|
'remote_path': url,
|
||||||
|
'file_size': stat.size,
|
||||||
|
'modified_time': stat.modified.millisecondsSinceEpoch,
|
||||||
|
'title': finalMetadata.title,
|
||||||
|
'artist': finalMetadata.artist,
|
||||||
|
'album': finalMetadata.album,
|
||||||
|
'genre': finalMetadata.genre,
|
||||||
|
'artwork_path': artworkPath,
|
||||||
|
'confidence': candidate.confidence,
|
||||||
|
'validation_count': 1,
|
||||||
|
'first_scan': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'last_scan': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'metadata_status': candidate.confidence >= 0.6 ? 'verified' : 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. 内存缓存
|
||||||
|
await _memoryCache.put(fileId, finalMetadata);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})');
|
||||||
|
return finalMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
FinalMetadata _fallbackFromFileName(String fileName) {
|
FinalMetadata _fallbackFromFileName(String fileName) {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// lib/models/playlist.dart
|
||||||
|
import 'package:qingting_player/services/audio_service.dart';
|
||||||
|
|
||||||
|
class Playlist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final PlayMode playMode;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
Playlist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.playMode = PlayMode.sequential,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now(),
|
||||||
|
updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'play_mode': Playlist.playModeToString(playMode), // ⭐ 改用公开静态方法
|
||||||
|
'created_at': createdAt.millisecondsSinceEpoch,
|
||||||
|
'updated_at': updatedAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Playlist.fromJson(Map<String, dynamic> json) => Playlist(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
playMode: Playlist.stringToPlayMode(
|
||||||
|
json['play_mode'] as String? ?? 'sequential'),
|
||||||
|
createdAt:
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
|
||||||
|
updatedAt:
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(json['updated_at'] as int),
|
||||||
|
);
|
||||||
|
|
||||||
|
Playlist copyWith({String? name, PlayMode? playMode}) => Playlist(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
playMode: playMode ?? this.playMode,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⭐ 改为公开静态方法
|
||||||
|
static String playModeToString(PlayMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case PlayMode.sequential:
|
||||||
|
return 'sequential';
|
||||||
|
case PlayMode.repeatOne:
|
||||||
|
return 'repeat_one';
|
||||||
|
case PlayMode.shuffle:
|
||||||
|
return 'shuffle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static PlayMode stringToPlayMode(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'repeat_one':
|
||||||
|
return PlayMode.repeatOne;
|
||||||
|
case 'shuffle':
|
||||||
|
return PlayMode.shuffle;
|
||||||
|
default:
|
||||||
|
return PlayMode.sequential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-12
@@ -6,6 +6,8 @@ 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 'webdav_file_list_page.dart';
|
import 'webdav_file_list_page.dart';
|
||||||
|
import '../constants/ui_constants.dart';
|
||||||
|
import '../base/base_state.dart';
|
||||||
|
|
||||||
// ==================================================
|
// ==================================================
|
||||||
// 歌曲数据模型
|
// 歌曲数据模型
|
||||||
@@ -148,7 +150,7 @@ class HomePage extends StatefulWidget {
|
|||||||
State<HomePage> createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends BaseState<HomePage> {
|
||||||
List<SongItem> _favorites = [];
|
List<SongItem> _favorites = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@@ -206,7 +208,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
if (renderBox != null) {
|
if (renderBox != null) {
|
||||||
final height = renderBox.size.height;
|
final height = renderBox.size.height;
|
||||||
if (height > 0 && height != _mediaLibraryHeight) {
|
if (height > 0 && height != _mediaLibraryHeight) {
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_mediaLibraryHeight = height;
|
_mediaLibraryHeight = height;
|
||||||
_heightMeasureRetryCount = 0;
|
_heightMeasureRetryCount = 0;
|
||||||
});
|
});
|
||||||
@@ -219,7 +221,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
|
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
|
||||||
} else {
|
} else {
|
||||||
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
|
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_mediaLibraryHeight = 280.0;
|
_mediaLibraryHeight = 280.0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -227,18 +229,18 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) safeSetState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initWebDAV() async {
|
Future<void> _initWebDAV() async {
|
||||||
setState(() => _isLoading = true);
|
safeSetState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
await WebDAVService.instance.loadCredentials();
|
await WebDAVService.instance.loadCredentials();
|
||||||
_favorites = [];
|
_favorites = [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_favorites = [];
|
_favorites = [];
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) safeSetState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,11 +283,58 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⭐ 退出/重新登录对话框
|
||||||
|
void _showReauthDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text(
|
||||||
|
'重新登录 WebDAV',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'退出当前账号并重新登录?',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await WebDAVService.instance.clearCredentials();
|
||||||
|
safeSetState(() {
|
||||||
|
_favorites = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
// ⭐ 修复:用 push 保留 HomePage 在栈底
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const WebDAVSetupPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'重新登录',
|
||||||
|
style: TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _resetSnapState() {
|
void _resetSnapState() {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.jumpTo(0.0);
|
_scrollController.jumpTo(0.0);
|
||||||
}
|
}
|
||||||
setState(() {});
|
safeSetState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -358,7 +407,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_resetSnapState();
|
_resetSnapState();
|
||||||
setState(() {});
|
safeSetState(() {});
|
||||||
} else {
|
} else {
|
||||||
final result = await Navigator.push(
|
final result = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -366,17 +415,18 @@ class _HomePageState extends State<HomePage> {
|
|||||||
builder: (_) => const WebDAVSetupPage(),
|
builder: (_) => const WebDAVSetupPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setState(() {});
|
safeSetState(() {});
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
await WebDAVService.instance
|
await WebDAVService.instance
|
||||||
.loadCredentials();
|
.loadCredentials();
|
||||||
setState(() {});
|
safeSetState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// 左侧云朵图标(固定 56px)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(left: 8.0),
|
padding: EdgeInsets.only(left: 8.0),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -386,11 +436,14 @@ class _HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
// 右侧所有内容
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment:
|
||||||
CrossAxisAlignment.start,
|
CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// 第一行:WebDAV + 状态 + 退出按钮
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
@@ -412,9 +465,24 @@ class _HomePageState extends State<HomePage> {
|
|||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (isConnected)
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(top: 2),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _showReauthDialog,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.logout,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
// 第二行:用户名
|
||||||
Text(
|
Text(
|
||||||
isConnected ? username : '点击连接',
|
isConnected ? username : '点击连接',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -553,7 +621,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 20,
|
bottom: UIConstants.miniPlayerBottomSpace,
|
||||||
),
|
),
|
||||||
sliver: _isLoading
|
sliver: _isLoading
|
||||||
? const SliverFillRemaining(
|
? const SliverFillRemaining(
|
||||||
@@ -639,7 +707,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_favorites.removeAt(index);
|
_favorites.removeAt(index);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 '../base/base_state.dart';
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
@@ -17,7 +18,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
State<PlayerPage> createState() => _PlayerPageState();
|
State<PlayerPage> createState() => _PlayerPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayerPageState extends State<PlayerPage> {
|
class _PlayerPageState extends BaseState<PlayerPage> {
|
||||||
double? _dragProgress;
|
double? _dragProgress;
|
||||||
|
|
||||||
String _formatDuration(Duration d) {
|
String _formatDuration(Duration d) {
|
||||||
@@ -130,9 +131,34 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
||||||
onPressed: () {},
|
onSelected: (value) async {
|
||||||
|
if (value == 'clear_cache') {
|
||||||
|
final service = context.read<AudioService>();
|
||||||
|
await service.clearCurrentSongCache();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('缓存已清除,重新加载中...'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear_cache',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cleaning_services, color: Colors.redAccent),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('清除缓存'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -154,12 +180,17 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
spreadRadius: 10,
|
spreadRadius: 10,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
image: song.artwork != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: MemoryImage(song.artwork!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: song.artwork == null
|
||||||
Icons.music_note,
|
? const Icon(Icons.music_note,
|
||||||
size: 64,
|
size: 64, color: Colors.white24)
|
||||||
color: Colors.white24,
|
: null,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+199
-20
@@ -1,10 +1,190 @@
|
|||||||
|
// lib/pages/playlist_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
|
||||||
class PlaylistPage extends StatelessWidget {
|
class PlaylistPage extends StatefulWidget {
|
||||||
const PlaylistPage({super.key});
|
const PlaylistPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistPage> createState() => _PlaylistPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistPageState extends State<PlaylistPage> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final GlobalKey _currentTagKey = GlobalKey();
|
||||||
|
|
||||||
|
int _scrollGeneration = 0;
|
||||||
|
bool _hasScrolledToCurrent = false;
|
||||||
|
|
||||||
|
// ⭐ 固定 item 高度(根据实际 ListTile 测量结果调整)
|
||||||
|
// 实际约 72-76px,取 74px 作为稳定值
|
||||||
|
static const double _itemExtent = 74.0;
|
||||||
|
|
||||||
|
// 舒适区范围
|
||||||
|
static const double _comfortZoneTop = 0.15;
|
||||||
|
static const double _comfortZoneBottom = 0.85;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToCurrentSong();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isItemInComfortZone(
|
||||||
|
RenderBox renderBox, double viewportHeight, double listTop) {
|
||||||
|
final itemGlobal = renderBox.localToGlobal(Offset.zero);
|
||||||
|
final itemTop = itemGlobal.dy - listTop;
|
||||||
|
final itemBottom = itemTop + renderBox.size.height;
|
||||||
|
|
||||||
|
final topBoundary = viewportHeight * _comfortZoneTop;
|
||||||
|
final bottomBoundary = viewportHeight * _comfortZoneBottom;
|
||||||
|
|
||||||
|
return itemTop >= topBoundary && itemBottom <= bottomBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scrollToCurrentSong() async {
|
||||||
|
if (_hasScrolledToCurrent) return;
|
||||||
|
|
||||||
|
final service = context.read<AudioService>();
|
||||||
|
final queue = service.queue;
|
||||||
|
final currentIndex = service.currentIndex;
|
||||||
|
|
||||||
|
if (queue.isEmpty || currentIndex < 0 || currentIndex >= queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final generation = ++_scrollGeneration;
|
||||||
|
|
||||||
|
// ⭐ 等待两帧,确保 ListView 完全稳定
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
if (!mounted || generation != _scrollGeneration) return;
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// 第一阶段:检测当前歌曲是否已经在舒适区内
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
var tagContext = _currentTagKey.currentContext;
|
||||||
|
|
||||||
|
if (tagContext != null) {
|
||||||
|
final renderBox = tagContext.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox != null) {
|
||||||
|
final viewportHeight = _scrollController.position.viewportDimension;
|
||||||
|
final listRenderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (listRenderBox != null) {
|
||||||
|
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
|
||||||
|
if (_isItemInComfortZone(renderBox, viewportHeight, listTop)) {
|
||||||
|
debugPrint('🎯 [定位] 当前歌曲已在舒适区内,不滚动');
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// 第二阶段:粗定位(使用固定 itemExtent,不再依赖 maxExtent)
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
if (tagContext == null) {
|
||||||
|
final viewportHeight = _scrollController.position.viewportDimension;
|
||||||
|
// ⭐ 固定高度计算,无需读取 maxExtent,更稳定更快
|
||||||
|
final roughOffset = (currentIndex * _itemExtent) - (viewportHeight * 0.3);
|
||||||
|
final maxExtent = _scrollController.position.maxScrollExtent;
|
||||||
|
final clamped = roughOffset.clamp(0.0, maxExtent);
|
||||||
|
|
||||||
|
// ⭐ 不可见瞬时跳转
|
||||||
|
_scrollController.jumpTo(clamped);
|
||||||
|
|
||||||
|
// 等待目标被构建(只需要一帧,因为高度固定,布局更快)
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
if (!mounted || generation != _scrollGeneration) return;
|
||||||
|
|
||||||
|
tagContext = _currentTagKey.currentContext;
|
||||||
|
|
||||||
|
// 如果还是 null,用迭代方式推进(但固定高度下很少需要)
|
||||||
|
if (tagContext == null) {
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
final viewportHeight2 = _scrollController.position.viewportDimension;
|
||||||
|
final direction = (currentIndex > queue.length / 2) ? -1 : 1;
|
||||||
|
final step = viewportHeight2 * 0.5 * direction;
|
||||||
|
final newOffset = (_scrollController.offset + step)
|
||||||
|
.clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||||
|
_scrollController.jumpTo(newOffset);
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
if (!mounted || generation != _scrollGeneration) return;
|
||||||
|
tagContext = _currentTagKey.currentContext;
|
||||||
|
if (tagContext != null) break;
|
||||||
|
}
|
||||||
|
if (tagContext == null) {
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || generation != _scrollGeneration) return;
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// 第三阶段:精确定位到舒适区
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
final renderBox = tagContext!.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox == null) {
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final listRenderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (listRenderBox == null) {
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itemGlobal = renderBox.localToGlobal(Offset.zero);
|
||||||
|
final listTop = listRenderBox.localToGlobal(Offset.zero).dy;
|
||||||
|
final viewportHeight = _scrollController.position.viewportDimension;
|
||||||
|
final itemHeight = renderBox.size.height;
|
||||||
|
|
||||||
|
final targetTop = viewportHeight * _comfortZoneTop;
|
||||||
|
final relativeOffset = itemGlobal.dy - listTop;
|
||||||
|
final currentOffset = _scrollController.offset;
|
||||||
|
final targetOffset = (currentOffset + relativeOffset - targetTop)
|
||||||
|
.clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||||
|
|
||||||
|
final distance = (targetOffset - currentOffset).abs();
|
||||||
|
|
||||||
|
if (distance < 5) {
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isFirstEntry = _scrollController.offset == 0 && currentIndex > 5;
|
||||||
|
final durationMs = isFirstEntry
|
||||||
|
? (150 + distance * 0.12).clamp(150, 350).round()
|
||||||
|
: (200 + distance * 0.15).clamp(200, 500).round();
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🎯 [定位] 偏移: ${currentOffset.toStringAsFixed(0)} → ${targetOffset.toStringAsFixed(0)}, '
|
||||||
|
'距离: ${distance.toStringAsFixed(0)}px, 时长: ${durationMs}ms');
|
||||||
|
|
||||||
|
await _scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: Duration(milliseconds: durationMs),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted && generation == _scrollGeneration) {
|
||||||
|
_hasScrolledToCurrent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final service = context.watch<AudioService>();
|
final service = context.watch<AudioService>();
|
||||||
@@ -30,6 +210,10 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(service.playModeIcon, color: Colors.white54),
|
||||||
|
onPressed: service.togglePlayMode,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear_all, color: Colors.white54),
|
icon: const Icon(Icons.clear_all, color: Colors.white54),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -37,29 +221,22 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: const Color(0xFF1A1F1E),
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
title: const Text(
|
title: const Text('清空播放列表',
|
||||||
'清空播放列表',
|
style: TextStyle(color: Colors.white)),
|
||||||
style: TextStyle(color: Colors.white),
|
content: const Text('确定要清空当前播放列表吗?',
|
||||||
),
|
style: TextStyle(color: Colors.grey)),
|
||||||
content: const Text(
|
|
||||||
'确定要清空当前播放列表吗?',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('取消'),
|
child: const Text('取消')),
|
||||||
),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
service.clearQueue();
|
service.clearQueue();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child:
|
||||||
'清空',
|
const Text('清空', style: TextStyle(color: Colors.red)),
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -75,15 +252,15 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
|
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text('播放列表为空', style: TextStyle(color: Colors.grey)),
|
||||||
'播放列表为空',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
// ⭐ 关键优化:固定 item 高度
|
||||||
|
itemExtent: _itemExtent,
|
||||||
itemCount: queue.length,
|
itemCount: queue.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final song = queue[index];
|
final song = queue[index];
|
||||||
@@ -121,12 +298,14 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
trailing: isCurrent
|
trailing: isCurrent
|
||||||
? Container(
|
? Container(
|
||||||
|
key: _currentTagKey,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFB8D4D0).withOpacity(0.2),
|
color:
|
||||||
|
const Color(0xFFB8D4D0).withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// lib/pages/webdav_file_list_page.dart
|
// lib/pages/webdav_file_list_page.dart
|
||||||
// ignore: unused_import
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import '../services/playback_service.dart';
|
import '../constants/ui_constants.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../base/base_state.dart';
|
||||||
|
import '../controllers/playback_controller.dart';
|
||||||
|
|
||||||
class WebDAVFileListPage extends StatefulWidget {
|
class WebDAVFileListPage extends StatefulWidget {
|
||||||
final String currentPath;
|
final String currentPath;
|
||||||
@@ -16,7 +14,7 @@ class WebDAVFileListPage extends StatefulWidget {
|
|||||||
State<WebDAVFileListPage> createState() => _WebDAVFileListPageState();
|
State<WebDAVFileListPage> createState() => _WebDAVFileListPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
||||||
List<WebDAVItem> _items = [];
|
List<WebDAVItem> _items = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String _errorMessage = '';
|
String _errorMessage = '';
|
||||||
@@ -42,7 +40,7 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
// 加载目录
|
// 加载目录
|
||||||
// ============================================================
|
// ============================================================
|
||||||
Future<void> _loadDirectory() async {
|
Future<void> _loadDirectory() async {
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = '';
|
_errorMessage = '';
|
||||||
});
|
});
|
||||||
@@ -50,17 +48,21 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
try {
|
try {
|
||||||
final items =
|
final items =
|
||||||
await WebDAVService.instance.listDirectory(path: _currentPath);
|
await WebDAVService.instance.listDirectory(path: _currentPath);
|
||||||
setState(() {
|
if (mounted) {
|
||||||
|
safeSetState(() {
|
||||||
_items = items;
|
_items = items;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
|
safeSetState(() {
|
||||||
_errorMessage = '加载失败: $e';
|
_errorMessage = '加载失败: $e';
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 进入子目录
|
// 进入子目录
|
||||||
@@ -75,71 +77,20 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ⭐ 核心:播放歌曲 + 自动构建队列
|
// ⭐ 播放单首(使用 PlaybackController)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
void _playSong(WebDAVItem file) async {
|
void _playSong(WebDAVItem file) async {
|
||||||
try {
|
try {
|
||||||
// 1. 获取当前目录所有音乐文件(过滤掉目录)
|
await PlaybackController().playFromWebDAV(
|
||||||
final musicFiles = _items.where((item) => !item.isDirectory).toList();
|
directoryPath: _currentPath,
|
||||||
|
clickedSongPath: file.path,
|
||||||
if (musicFiles.isEmpty) {
|
allItems: _items,
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('当前目录没有音乐文件'),
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 获取认证头
|
|
||||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
|
||||||
|
|
||||||
// 3. 构建播放队列(所有音乐文件)
|
|
||||||
final queue = musicFiles.map((item) {
|
|
||||||
final itemUrl = WebDAVService.instance.getFileUrl(item.path);
|
|
||||||
return Song(
|
|
||||||
id: item.path,
|
|
||||||
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
|
||||||
artist: '未知艺术家',
|
|
||||||
url: itemUrl,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// 4. 找到当前点击歌曲在队列中的位置
|
|
||||||
final startIndex = queue.indexWhere((s) => s.id == file.path);
|
|
||||||
if (startIndex == -1) {
|
|
||||||
// 极端情况:队列构建有问题,退化为单曲播放
|
|
||||||
final url = WebDAVService.instance.getFileUrl(file.path);
|
|
||||||
final song = Song(
|
|
||||||
id: file.path,
|
|
||||||
title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
|
||||||
artist: '未知艺术家',
|
|
||||||
url: url,
|
|
||||||
);
|
|
||||||
context.read<AudioService>().setQueue([song], startIndex: 0);
|
|
||||||
await PlaybackService().play(url, headers: headers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 设置队列并播放
|
|
||||||
final audioService = context.read<AudioService>();
|
|
||||||
audioService.setQueue(queue, startIndex: startIndex);
|
|
||||||
|
|
||||||
// 6. 播放(AudioService 内部已经调用了 PlaybackService,但为了确保认证头传递)
|
|
||||||
// 这里再显式调用一下,确保认证头正确
|
|
||||||
final targetUrl = queue[startIndex].url!;
|
|
||||||
await PlaybackService().play(targetUrl, headers: headers);
|
|
||||||
|
|
||||||
// 7. 更新 AudioService 的播放状态(确保 UI 同步)
|
|
||||||
// setQueue 已经调用了 _playCurrent(),所以这里不需要重复调用
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('正在播放: ${file.name}'),
|
content: Text('正在播放: ${_safeDecode(file.name)}'),
|
||||||
backgroundColor: const Color(0xFF4CAF50),
|
backgroundColor: const Color(0xFF4CAF50),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
@@ -158,46 +109,20 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 全部播放(从第一首开始)
|
// ⭐ 全部播放(使用 PlaybackController)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
void _playAll() async {
|
void _playAll() async {
|
||||||
try {
|
try {
|
||||||
final musicFiles = _items.where((item) => !item.isDirectory).toList();
|
await PlaybackController().playAllFromWebDAV(
|
||||||
|
directoryPath: _currentPath,
|
||||||
if (musicFiles.isEmpty) {
|
allItems: _items,
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('当前目录没有音乐文件'),
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
|
||||||
|
|
||||||
final queue = musicFiles.map((item) {
|
|
||||||
final itemUrl = WebDAVService.instance.getFileUrl(item.path);
|
|
||||||
return Song(
|
|
||||||
id: item.path,
|
|
||||||
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
|
||||||
artist: '未知艺术家',
|
|
||||||
url: itemUrl,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final audioService = context.read<AudioService>();
|
|
||||||
audioService.setQueue(queue, startIndex: 0);
|
|
||||||
|
|
||||||
final targetUrl = queue[0].url!;
|
|
||||||
await PlaybackService().play(targetUrl, headers: headers);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
final musicCount = _items.where((item) => !item.isDirectory).length;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('开始播放全部 (${queue.length}首)'),
|
content: Text('开始播放全部 ($musicCount 首)'),
|
||||||
backgroundColor: const Color(0xFF4CAF50),
|
backgroundColor: const Color(0xFF4CAF50),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
@@ -249,7 +174,6 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
// 全部播放按钮
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.playlist_play),
|
icon: const Icon(Icons.playlist_play),
|
||||||
onPressed: _playAll,
|
onPressed: _playAll,
|
||||||
@@ -315,8 +239,12 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.only(
|
||||||
horizontal: 16, vertical: 8),
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 8,
|
||||||
|
bottom: UIConstants.miniPlayerBottomSpace,
|
||||||
|
),
|
||||||
itemCount: _items.length,
|
itemCount: _items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _items[index];
|
final item = _items[index];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import 'webdav_file_list_page.dart';
|
import 'webdav_file_list_page.dart';
|
||||||
|
import '../base/base_state.dart';
|
||||||
|
|
||||||
class WebDAVSetupPage extends StatefulWidget {
|
class WebDAVSetupPage extends StatefulWidget {
|
||||||
const WebDAVSetupPage({super.key});
|
const WebDAVSetupPage({super.key});
|
||||||
@@ -9,7 +10,7 @@ class WebDAVSetupPage extends StatefulWidget {
|
|||||||
State<WebDAVSetupPage> createState() => _WebDAVSetupPageState();
|
State<WebDAVSetupPage> createState() => _WebDAVSetupPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
class _WebDAVSetupPageState extends BaseState<WebDAVSetupPage> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _baseUrlController = TextEditingController();
|
final _baseUrlController = TextEditingController();
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
@@ -34,7 +35,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
|
|
||||||
Future<void> _loadSavedCredentials() async {
|
Future<void> _loadSavedCredentials() async {
|
||||||
final hasCred = await WebDAVService.instance.loadCredentials();
|
final hasCred = await WebDAVService.instance.loadCredentials();
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_isConnected = hasCred;
|
_isConnected = hasCred;
|
||||||
_statusText = hasCred ? '已连接' : '未连接';
|
_statusText = hasCred ? '已连接' : '未连接';
|
||||||
if (hasCred) {
|
if (hasCred) {
|
||||||
@@ -46,7 +47,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
safeSetState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await WebDAVService.instance.saveCredentials(
|
await WebDAVService.instance.saveCredentials(
|
||||||
@@ -58,7 +59,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
// 验证连接
|
// 验证连接
|
||||||
await WebDAVService.instance.getMusicFiles();
|
await WebDAVService.instance.getMusicFiles();
|
||||||
|
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_isConnected = true;
|
_isConnected = true;
|
||||||
_statusText = '已连接';
|
_statusText = '已连接';
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -71,7 +72,8 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
backgroundColor: Color(0xFF4CAF50),
|
backgroundColor: Color(0xFF4CAF50),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// ✅ 登录成功 → 直接跳转到文件列表页
|
// ⭐ 用 pushReplacement 替换当前页面(WebDAVSetupPage)
|
||||||
|
// 此时栈底是 HomePage,替换后栈变为 [HomePage, WebDAVFileListPage]
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -80,7 +82,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_statusText = '连接失败';
|
_statusText = '连接失败';
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -98,7 +100,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
|
|||||||
|
|
||||||
Future<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
await WebDAVService.instance.clearCredentials();
|
await WebDAVService.instance.clearCredentials();
|
||||||
setState(() {
|
safeSetState(() {
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_statusText = '未连接';
|
_statusText = '未连接';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// lib/repositories/playlist_repository.dart
|
||||||
|
import 'dart:math';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
|
||||||
|
class PlaylistRepository {
|
||||||
|
final SongDatabase _db = SongDatabase();
|
||||||
|
|
||||||
|
String _generateId() {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
final random = Random();
|
||||||
|
return String.fromCharCodes(
|
||||||
|
Iterable.generate(
|
||||||
|
16, (_) => chars.codeUnitAt(random.nextInt(chars.length))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 歌单操作
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<List<Playlist>> getAllPlaylists() async {
|
||||||
|
final data = await _db.getAllPlaylists();
|
||||||
|
return data.map((e) => Playlist.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Playlist?> getPlaylist(String id) async {
|
||||||
|
final data = await _db.getPlaylist(id);
|
||||||
|
if (data == null) return null;
|
||||||
|
return Playlist.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Playlist> createPlaylist(String name) async {
|
||||||
|
final playlist = Playlist(id: _generateId(), name: name);
|
||||||
|
await _db.insertPlaylist(playlist.toJson());
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updatePlaylistName(String id, String newName) async {
|
||||||
|
await _db.updatePlaylist(id, {
|
||||||
|
'name': newName,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ 新增:更新播放模式
|
||||||
|
Future<void> updatePlaylistPlayMode(String id, PlayMode playMode) async {
|
||||||
|
await _db.updatePlaylist(id, {
|
||||||
|
// ⭐ 修改前:_playModeToString
|
||||||
|
// 'play_mode': _playModeToString(playMode),
|
||||||
|
|
||||||
|
// ⭐ 修改后:用公开静态方法
|
||||||
|
'play_mode': Playlist.playModeToString(playMode),
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePlaylist(String id) async {
|
||||||
|
await _db.deletePlaylist(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 歌单歌曲操作
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<List<String>> getPlaylistSongIds(String playlistId) async {
|
||||||
|
final data = await _db.getPlaylistSongs(playlistId);
|
||||||
|
return data.map((e) => e['song_id'] as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSong(String playlistId, String songId) async {
|
||||||
|
await _db.addSongToPlaylist(playlistId, songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSong(String playlistId, String songId) async {
|
||||||
|
await _db.removeSongFromPlaylist(playlistId, songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reorderSongs(
|
||||||
|
String playlistId, int oldIndex, int newIndex) async {
|
||||||
|
if (oldIndex == newIndex) return;
|
||||||
|
final songs = await _db.getPlaylistSongs(playlistId);
|
||||||
|
if (oldIndex < 0 ||
|
||||||
|
oldIndex >= songs.length ||
|
||||||
|
newIndex < 0 ||
|
||||||
|
newIndex >= songs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final items = songs.map((e) => e['song_id'] as String).toList();
|
||||||
|
final item = items.removeAt(oldIndex);
|
||||||
|
items.insert(newIndex, item);
|
||||||
|
for (int i = 0; i < items.length; i++) {
|
||||||
|
await _db.removeSongFromPlaylist(playlistId, items[i]);
|
||||||
|
await _db.addSongToPlaylist(playlistId, items[i], orderIndex: i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPlaylist(String playlistId) async {
|
||||||
|
await _db.clearPlaylist(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 收藏操作
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<List<String>> getFavorites() async {
|
||||||
|
final data = await _db.getFavorites();
|
||||||
|
return data.map((e) => e['song_id'] as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isFavorite(String songId) async {
|
||||||
|
return await _db.isFavorite(songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addFavorite(String songId) async {
|
||||||
|
await _db.addFavorite(songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFavorite(String songId) async {
|
||||||
|
await _db.removeFavorite(songId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleFavorite(String songId) async {
|
||||||
|
await _db.toggleFavorite(songId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
// lib/services/app_lifecycle_manager.dart
|
// lib/services/app_lifecycle_manager.dart
|
||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
|
||||||
/// App 生命周期状态(扩展了 Flutter 原生状态)
|
/// App 生命周期状态(扩展了 Flutter 原生状态)
|
||||||
enum AppLifecycleStatus {
|
enum AppLifecycleStatus {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// lib/services/audio_player_handler.dart
|
// lib/services/audio_player_handler.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.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/player_controller.dart';
|
||||||
import '../audio/playback_state_manager.dart';
|
import '../audio/playback_state_manager.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../utils/file_provider_utils.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
||||||
final PlayerController _player = PlayerController();
|
final PlayerController _player = PlayerController();
|
||||||
@@ -14,18 +19,19 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
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;
|
||||||
|
|
||||||
AudioPlayerHandler() {
|
AudioPlayerHandler() {
|
||||||
_player.playingStream.listen((playing) {
|
_player.playingStream.listen((playing) {
|
||||||
_state.updatePlaying(playing);
|
_state.updatePlaying(playing);
|
||||||
_publishState();
|
_publishState();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ⭐ 位置更新:直接赋值给 _currentPosition
|
|
||||||
_player.positionStream.listen((position) {
|
_player.positionStream.listen((position) {
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_state.updatePosition(position);
|
_state.updatePosition(position);
|
||||||
@@ -33,7 +39,7 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
||||||
_lastPublishTime = now;
|
_lastPublishTime = now;
|
||||||
_updateMediaItemPosition(position);
|
// ⭐ 只保留 _publishStateOnly()
|
||||||
_publishStateOnly();
|
_publishStateOnly();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -53,22 +59,7 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateMediaItemPosition(Duration position) {
|
// ---- 发布状态 ----
|
||||||
final currentMediaItem = mediaItem.value;
|
|
||||||
if (currentMediaItem != null) {
|
|
||||||
mediaItem.add(audio_service.MediaItem(
|
|
||||||
id: currentMediaItem.id,
|
|
||||||
title: currentMediaItem.title,
|
|
||||||
artist: currentMediaItem.artist,
|
|
||||||
duration: currentMediaItem.duration,
|
|
||||||
extras: {
|
|
||||||
'position': position.inMilliseconds,
|
|
||||||
...?currentMediaItem.extras,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _publishState() {
|
void _publishState() {
|
||||||
final state = _state.playbackState;
|
final state = _state.playbackState;
|
||||||
playbackState.add(audio_service.PlaybackState(
|
playbackState.add(audio_service.PlaybackState(
|
||||||
@@ -76,71 +67,136 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
processingState: state.processingState,
|
processingState: state.processingState,
|
||||||
playing: state.playing,
|
playing: state.playing,
|
||||||
androidCompactActionIndices: state.androidCompactActionIndices,
|
androidCompactActionIndices: state.androidCompactActionIndices,
|
||||||
|
updatePosition: _currentPosition,
|
||||||
updateTime: DateTime.now(),
|
updateTime: DateTime.now(),
|
||||||
|
systemActions: const {
|
||||||
|
audio_service.MediaAction.seek,
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _publishStateOnly() {
|
void _publishStateOnly() {
|
||||||
final current = playbackState.value;
|
final current = playbackState.value;
|
||||||
|
|
||||||
debugPrint(
|
playbackState.add(audio_service.PlaybackState(
|
||||||
'📡 [publishStateOnly] position=$_currentPosition, playing=${current.playing}',
|
|
||||||
);
|
|
||||||
|
|
||||||
playbackState.add(
|
|
||||||
audio_service.PlaybackState(
|
|
||||||
controls: current.controls.isNotEmpty
|
controls: current.controls.isNotEmpty
|
||||||
? current.controls
|
? current.controls
|
||||||
: _state.playbackState.controls,
|
: _state.playbackState.controls,
|
||||||
processingState: _state.playbackState.processingState,
|
processingState: _state.playbackState.processingState,
|
||||||
playing: current.playing,
|
playing: current.playing,
|
||||||
androidCompactActionIndices: current.androidCompactActionIndices,
|
androidCompactActionIndices: current.androidCompactActionIndices,
|
||||||
|
updatePosition: _currentPosition,
|
||||||
// ⭐ 声明支持 seek
|
updateTime: DateTime.now(),
|
||||||
systemActions: const {
|
systemActions: const {
|
||||||
audio_service.MediaAction.seek,
|
audio_service.MediaAction.seek,
|
||||||
},
|
},
|
||||||
|
));
|
||||||
updatePosition: _currentPosition,
|
|
||||||
updateTime: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 更新媒体信息 ----
|
||||||
void _updateMediaItem({
|
void _updateMediaItem({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
required String artist,
|
required String artist,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
|
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;
|
final position = _player.position;
|
||||||
|
|
||||||
debugPrint(
|
debugPrint('📢 [handler] updateMediaItem: $title - $artist');
|
||||||
'📢 [handler] updateMediaItem: $title - $artist (position=${position.inSeconds}s)');
|
|
||||||
|
|
||||||
|
// ⭐ 异步处理 artwork(不阻塞主流程)
|
||||||
|
_handleArtwork(id, artwork).then((artUri) {
|
||||||
|
// 如果 artUri 变化,重新推送 MediaItem
|
||||||
|
final current = mediaItem.value;
|
||||||
|
if (current != null && current.artUri != artUri) {
|
||||||
|
debugPrint('📢 [handler] updating artUri: $artUri');
|
||||||
|
mediaItem.add(audio_service.MediaItem(
|
||||||
|
id: current.id,
|
||||||
|
title: current.title,
|
||||||
|
artist: current.artist,
|
||||||
|
duration: current.duration,
|
||||||
|
artUri: artUri,
|
||||||
|
extras: current.extras,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 先推送不带封面图的 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 ?? _player.duration,
|
||||||
extras: {
|
extras: {'position': position.inMilliseconds},
|
||||||
'position': position.inMilliseconds,
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理封面图:保存到本地并生成 content URI
|
||||||
|
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
||||||
|
if (artwork == null || artwork.isEmpty) {
|
||||||
|
_currentArtworkPath = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final artworkDir = Directory('${dir.path}/artworks');
|
||||||
|
if (!await artworkDir.exists()) {
|
||||||
|
await artworkDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 md5 生成安全的文件名
|
||||||
|
final bytes = utf8.encode(id);
|
||||||
|
final digest = md5.convert(bytes);
|
||||||
|
final fileName = '$digest.jpg';
|
||||||
|
final path = '${artworkDir.path}/$fileName';
|
||||||
|
final file = File(path);
|
||||||
|
|
||||||
|
// 检查文件是否已存在
|
||||||
|
if (await file.exists()) {
|
||||||
|
final existingBytes = await file.readAsBytes();
|
||||||
|
if (existingBytes.length == artwork.length &&
|
||||||
|
existingBytes.hashCode == artwork.hashCode) {
|
||||||
|
_currentArtworkPath = path;
|
||||||
|
return await FileProviderUtils.getContentUri(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入新文件
|
||||||
|
await file.writeAsBytes(artwork);
|
||||||
|
_currentArtworkPath = path;
|
||||||
|
debugPrint('📢 [handler] artwork saved: $path');
|
||||||
|
|
||||||
|
return await FileProviderUtils.getContentUri(file);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 外部接口 ----
|
||||||
void updateNotification({
|
void updateNotification({
|
||||||
required String id,
|
required String id,
|
||||||
required String title,
|
required String title,
|
||||||
required String artist,
|
required String artist,
|
||||||
|
Uint8List? artwork,
|
||||||
}) {
|
}) {
|
||||||
_updateMediaItem(id: id, title: title, artist: artist);
|
_updateMediaItem(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
artwork: artwork,
|
||||||
|
);
|
||||||
_publishState();
|
_publishState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 控制命令 ----
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
debugPrint('▶️ [handler] play() called');
|
debugPrint('▶️ [handler] play() called');
|
||||||
@@ -167,7 +223,6 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
|||||||
debugPrint('⏩ [handler] seek() called: $position');
|
debugPrint('⏩ [handler] seek() called: $position');
|
||||||
await _player.seek(position);
|
await _player.seek(position);
|
||||||
_currentPosition = position;
|
_currentPosition = position;
|
||||||
_updateMediaItemPosition(position);
|
|
||||||
_publishStateOnly();
|
_publishStateOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+538
-28
@@ -1,9 +1,18 @@
|
|||||||
// lib/services/audio_service.dart
|
// lib/services/audio_service.dart
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart' as media_kit;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'playback_service.dart';
|
import 'playback_service.dart';
|
||||||
import '../metadata/metadata_service.dart';
|
import '../metadata/metadata_service.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../utils/artwork_helper.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
|
import 'webdav_service.dart';
|
||||||
|
|
||||||
enum PlayMode {
|
enum PlayMode {
|
||||||
sequential,
|
sequential,
|
||||||
@@ -16,13 +25,22 @@ class Song {
|
|||||||
final String title;
|
final String title;
|
||||||
final String artist;
|
final String artist;
|
||||||
final String? url;
|
final String? url;
|
||||||
|
final Uint8List? artwork;
|
||||||
|
|
||||||
Song({
|
Song({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.url,
|
this.url,
|
||||||
|
this.artwork,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'artist': artist,
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class AudioService extends ChangeNotifier {
|
class AudioService extends ChangeNotifier {
|
||||||
@@ -41,6 +59,9 @@ class AudioService extends ChangeNotifier {
|
|||||||
List<int> _shuffledIndices = [];
|
List<int> _shuffledIndices = [];
|
||||||
int _shuffledIndex = -1;
|
int _shuffledIndex = -1;
|
||||||
|
|
||||||
|
// ---- 当前播放的歌单 ID ----
|
||||||
|
String? _currentPlaylistId;
|
||||||
|
|
||||||
// ---- 高频进度 ----
|
// ---- 高频进度 ----
|
||||||
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);
|
||||||
@@ -49,9 +70,23 @@ class AudioService extends ChangeNotifier {
|
|||||||
bool _listening = false;
|
bool _listening = false;
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
|
// ---- 播放生命周期标志 ----
|
||||||
bool _handlingCompletion = false;
|
bool _handlingCompletion = false;
|
||||||
|
bool _isChangingTrack = false;
|
||||||
|
bool _hasStartedCurrentPlayback = false;
|
||||||
|
bool _isUserSeeking = false;
|
||||||
|
|
||||||
|
int _playbackGeneration = 0;
|
||||||
|
|
||||||
|
// ---- 待恢复的播放进度 ----
|
||||||
|
Duration? _pendingSeekPosition;
|
||||||
|
|
||||||
void Function(Song)? _onSongChanged;
|
void Function(Song)? _onSongChanged;
|
||||||
|
|
||||||
|
// ---- Repository ----
|
||||||
|
final SongDatabase _db = SongDatabase();
|
||||||
|
final PlaylistRepository _playlistRepo = PlaylistRepository();
|
||||||
|
|
||||||
// ---- Getter ----
|
// ---- Getter ----
|
||||||
Song? get currentSong => _currentSong;
|
Song? get currentSong => _currentSong;
|
||||||
bool get isPlaying => _isPlaying;
|
bool get isPlaying => _isPlaying;
|
||||||
@@ -59,6 +94,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
List<Song> get queue => List.unmodifiable(_queue);
|
List<Song> get queue => List.unmodifiable(_queue);
|
||||||
int get currentIndex => _currentIndex;
|
int get currentIndex => _currentIndex;
|
||||||
bool get hasQueue => _queue.isNotEmpty;
|
bool get hasQueue => _queue.isNotEmpty;
|
||||||
|
String? get currentPlaylistId => _currentPlaylistId;
|
||||||
|
|
||||||
Duration get position => positionNotifier.value;
|
Duration get position => positionNotifier.value;
|
||||||
Duration get duration => durationNotifier.value;
|
Duration get duration => durationNotifier.value;
|
||||||
@@ -92,9 +128,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
if (_currentPlaylistId != null) {
|
||||||
|
_playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode);
|
||||||
|
}
|
||||||
|
savePlaybackState(); // 模式改变时保存
|
||||||
}
|
}
|
||||||
|
|
||||||
void setQueue(List<Song> queue, {int startIndex = 0}) {
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 队列管理
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> setQueue(List<Song> queue, {int startIndex = 0}) async {
|
||||||
if (queue.isEmpty) {
|
if (queue.isEmpty) {
|
||||||
_clearQueue();
|
_clearQueue();
|
||||||
return;
|
return;
|
||||||
@@ -103,6 +148,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
_queue = List.from(queue);
|
_queue = List.from(queue);
|
||||||
_currentIndex = startIndex.clamp(0, _queue.length - 1);
|
_currentIndex = startIndex.clamp(0, _queue.length - 1);
|
||||||
|
|
||||||
|
if (_playMode == PlayMode.shuffle) {
|
||||||
_shuffledIndices = List.generate(_queue.length, (i) => i);
|
_shuffledIndices = List.generate(_queue.length, (i) => i);
|
||||||
_shuffledIndices.shuffle();
|
_shuffledIndices.shuffle();
|
||||||
_shuffledIndex = _shuffledIndices.indexOf(_currentIndex);
|
_shuffledIndex = _shuffledIndices.indexOf(_currentIndex);
|
||||||
@@ -110,8 +156,12 @@ class AudioService extends ChangeNotifier {
|
|||||||
_shuffledIndex = 0;
|
_shuffledIndex = 0;
|
||||||
_currentIndex = _shuffledIndices[0];
|
_currentIndex = _shuffledIndices[0];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_shuffledIndices = List.generate(_queue.length, (i) => i);
|
||||||
|
_shuffledIndex = _currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearQueue() {
|
void _clearQueue() {
|
||||||
@@ -119,23 +169,98 @@ class AudioService extends ChangeNotifier {
|
|||||||
_currentIndex = -1;
|
_currentIndex = -1;
|
||||||
_shuffledIndices.clear();
|
_shuffledIndices.clear();
|
||||||
_shuffledIndex = -1;
|
_shuffledIndex = -1;
|
||||||
|
_currentPlaylistId = null;
|
||||||
stopPlay();
|
stopPlay();
|
||||||
|
// 清空队列时也清除持久化状态
|
||||||
|
_db.savePlaybackState(
|
||||||
|
queueJson: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
playMode: 'sequential',
|
||||||
|
positionMs: 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playSong(Song song) async {
|
Future<void> playSong(Song song) async {
|
||||||
|
_currentPlaylistId = null;
|
||||||
if (_queue.isEmpty || _queue[_currentIndex].id != song.id) {
|
if (_queue.isEmpty || _queue[_currentIndex].id != song.id) {
|
||||||
setQueue([song], startIndex: 0);
|
await setQueue([song], startIndex: 0);
|
||||||
} else {
|
} else {
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playCurrent() {
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// 歌单
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> playPlaylist(String playlistId, {int startIndex = 0}) async {
|
||||||
|
_currentPlaylistId = playlistId;
|
||||||
|
|
||||||
|
final playlist = await _playlistRepo.getPlaylist(playlistId);
|
||||||
|
if (playlist != null) {
|
||||||
|
_playMode = playlist.playMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
final songIds = await _playlistRepo.getPlaylistSongIds(playlistId);
|
||||||
|
if (songIds.isEmpty) return;
|
||||||
|
|
||||||
|
final songs = <Song>[];
|
||||||
|
for (final id in songIds) {
|
||||||
|
final dbSong = await _db.getSongByPath(id);
|
||||||
|
if (dbSong != null) {
|
||||||
|
songs.add(Song(
|
||||||
|
id: dbSong['remote_path'] as String,
|
||||||
|
title: dbSong['title'] as String? ?? '',
|
||||||
|
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
||||||
|
url: dbSong['remote_path'] as String,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
songs.add(Song(
|
||||||
|
id: id,
|
||||||
|
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
|
artist: '未知艺术家',
|
||||||
|
url: id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (songs.isNotEmpty) {
|
||||||
|
await setQueue(songs, startIndex: startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Playlist> saveQueueAsPlaylist(String name) async {
|
||||||
|
final playlist = await _playlistRepo.createPlaylist(name);
|
||||||
|
for (int i = 0; i < _queue.length; i++) {
|
||||||
|
final song = _queue[i];
|
||||||
|
await _playlistRepo.addSong(playlist.id, song.id);
|
||||||
|
}
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 播放核心
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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;
|
||||||
|
_handlingCompletion = false;
|
||||||
|
_hasStartedCurrentPlayback = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_playbackGeneration++;
|
||||||
|
final generation = _playbackGeneration;
|
||||||
|
|
||||||
final song = _queue[_currentIndex];
|
final song = _queue[_currentIndex];
|
||||||
_currentSong = song;
|
_currentSong = song;
|
||||||
|
|
||||||
@@ -146,18 +271,95 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackService().play(song.url!);
|
await _playWithHeaders(song.url!);
|
||||||
|
await _waitForPlaybackStarted();
|
||||||
|
|
||||||
// ⭐ 延迟同步兜底
|
// ⭐ 如果有待恢复的进度,执行 seek
|
||||||
_syncPlayerStateDelayed();
|
if (_pendingSeekPosition != null &&
|
||||||
|
_pendingSeekPosition!.inMilliseconds > 0) {
|
||||||
_loadMetadataForCurrentSong();
|
final pos = _pendingSeekPosition!;
|
||||||
|
_pendingSeekPosition = null;
|
||||||
|
await PlaybackService().seek(pos);
|
||||||
|
positionNotifier.value = pos;
|
||||||
|
debugPrint('🎯 [AudioService] restored position: $pos');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMetadataForCurrentSong() async {
|
_syncPlayerStateDelayed();
|
||||||
|
|
||||||
|
if (_hasStartedCurrentPlayback) {
|
||||||
|
_onSongChanged?.call(song);
|
||||||
|
_loadMetadataForCurrentSong(generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切歌完成后保存状态
|
||||||
|
await savePlaybackState();
|
||||||
|
} finally {
|
||||||
|
_isChangingTrack = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitForPlaybackStarted() async {
|
||||||
|
final player = PlaybackService().player;
|
||||||
|
|
||||||
|
if (player.state.playing) {
|
||||||
|
_hasStartedCurrentPlayback = true;
|
||||||
|
debugPrint('🎵 [AudioService] playback already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await player.stream.playing
|
||||||
|
.where((playing) => playing == true)
|
||||||
|
.first
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
_hasStartedCurrentPlayback = true;
|
||||||
|
debugPrint('🎵 [AudioService] playback started');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [AudioService] wait for playback timeout: $e');
|
||||||
|
_hasStartedCurrentPlayback = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playWithHeaders(String url) async {
|
||||||
|
final headers = await _getAuthHeadersForUrl(url);
|
||||||
|
await PlaybackService().play(url, headers: headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _getAuthHeadersForUrl(String url) async {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||||
|
if (headers.isNotEmpty) {
|
||||||
|
debugPrint('🔑 [AudioService] 已添加认证头到播放请求');
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [AudioService] 获取认证头失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
||||||
|
final raw = '$url|$fileSize|$modifiedTime';
|
||||||
|
return raw.hashCode.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ Metadata 加载(四层校验)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> _loadMetadataForCurrentSong(int generation) async {
|
||||||
if (_currentIndex < 0 || _currentIndex >= _queue.length) return;
|
if (_currentIndex < 0 || _currentIndex >= _queue.length) return;
|
||||||
final song = _queue[_currentIndex];
|
|
||||||
if (song.url == null || song.url!.isEmpty) return;
|
final currentIndex = _currentIndex;
|
||||||
|
final song = _queue[currentIndex];
|
||||||
|
final songId = song.id;
|
||||||
|
|
||||||
|
if (song.url == null || song.url!.isEmpty) {
|
||||||
|
_onSongChanged?.call(song);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = await MetadataService().getMetadata(
|
final metadata = await MetadataService().getMetadata(
|
||||||
@@ -166,43 +368,149 @@ class AudioService extends ChangeNotifier {
|
|||||||
fileId: song.id,
|
fileId: song.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 第一层:generation 校验
|
||||||
|
if (_playbackGeneration != generation) {
|
||||||
|
debugPrint('⚠️ [AudioService] metadata stale (generation)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二层:索引校验
|
||||||
|
if (_currentIndex != currentIndex) {
|
||||||
|
debugPrint('⚠️ [AudioService] metadata stale (index changed)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三层:歌曲 ID 校验
|
||||||
|
if (_currentIndex >= _queue.length ||
|
||||||
|
_queue[_currentIndex].id != songId) {
|
||||||
|
debugPrint('⚠️ [AudioService] metadata stale (song changed)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第四层:_currentSong 校验
|
||||||
|
if (_currentSong == null || _currentSong!.id != songId) {
|
||||||
|
debugPrint('⚠️ [AudioService] metadata stale: current song mismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata.isNotEmpty) {
|
if (metadata.isNotEmpty) {
|
||||||
final updatedSong = Song(
|
final updatedSong = Song(
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: metadata.title.isNotEmpty ? metadata.title : song.title,
|
title: metadata.title.isNotEmpty ? metadata.title : song.title,
|
||||||
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
||||||
url: song.url,
|
url: song.url,
|
||||||
|
artwork: metadata.artwork,
|
||||||
);
|
);
|
||||||
_queue[_currentIndex] = updatedSong;
|
_queue[_currentIndex] = updatedSong;
|
||||||
_currentSong = updatedSong;
|
_currentSong = updatedSong;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_onSongChanged?.call(updatedSong);
|
_onSongChanged?.call(updatedSong);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [AudioService] metadata updated: ${updatedSong.title} - ${updatedSong.artist} (gen $generation)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [AudioService] metadata load failed: $e');
|
debugPrint('⚠️ [AudioService] metadata load failed: $e');
|
||||||
|
_onSongChanged?.call(song);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void next() {
|
Future<void> clearCurrentSongCache() async {
|
||||||
if (_queue.isEmpty) return;
|
if (_currentSong == null) return;
|
||||||
|
final song = _currentSong!;
|
||||||
|
final url = song.url ?? '';
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
|
||||||
|
debugPrint('🗑️ [AudioService] clearing cache for: ${song.title}');
|
||||||
|
|
||||||
|
final currentIndex = _currentIndex;
|
||||||
|
|
||||||
|
String songKey;
|
||||||
|
try {
|
||||||
|
final provider = MetadataService().getProviderForUrl(url);
|
||||||
|
final file = await provider.getFile(url);
|
||||||
|
if (file != null && await file.exists()) {
|
||||||
|
final stat = await file.stat();
|
||||||
|
songKey = _generateSongKey(
|
||||||
|
url, stat.size, stat.modified.millisecondsSinceEpoch);
|
||||||
|
} else {
|
||||||
|
songKey = url.hashCode.toString();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
songKey = url.hashCode.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = SongDatabase();
|
||||||
|
await db.deleteSong(songKey);
|
||||||
|
await db.deleteCache(songKey);
|
||||||
|
debugPrint('🗑️ [AudioService] SQLite records deleted: $songKey');
|
||||||
|
|
||||||
|
await ArtworkHelper.deleteArtwork(songKey);
|
||||||
|
debugPrint('🗑️ [AudioService] artwork deleted');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final cacheDir = await getTemporaryDirectory();
|
||||||
|
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
||||||
|
final cacheFile = File(cachePath);
|
||||||
|
if (await cacheFile.exists()) {
|
||||||
|
await cacheFile.delete();
|
||||||
|
debugPrint('🗑️ [AudioService] temp cache file deleted');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
await MetadataService().clearCache(song.id);
|
||||||
|
debugPrint('🗑️ [AudioService] memory cache cleared');
|
||||||
|
|
||||||
|
if (currentIndex >= 0 && currentIndex < _queue.length) {
|
||||||
|
stopPlay();
|
||||||
|
await _playCurrent();
|
||||||
|
debugPrint('🔄 [AudioService] song reloaded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 上一首 / 下一首(带防并发)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Future<void> next() async {
|
||||||
|
debugPrint(
|
||||||
|
'🎵 [next] CALLED: index=$_currentIndex, total=${_queue.length}, isChangingTrack=$_isChangingTrack');
|
||||||
|
|
||||||
|
if (_queue.isEmpty) {
|
||||||
|
debugPrint('⚠️ [next] queue is empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isChangingTrack) {
|
||||||
|
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;
|
||||||
_shuffledIndex = nextIdx;
|
_shuffledIndex = nextIdx;
|
||||||
_currentIndex = _shuffledIndices[nextIdx];
|
_currentIndex = _shuffledIndices[nextIdx];
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final nextIdx = (_currentIndex + 1) % _queue.length;
|
final nextIdx = (_currentIndex + 1) % _queue.length;
|
||||||
_currentIndex = nextIdx;
|
_currentIndex = nextIdx;
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
void previous() {
|
Future<void> previous() async {
|
||||||
if (_queue.isEmpty) return;
|
if (_queue.isEmpty) return;
|
||||||
|
|
||||||
|
if (_isChangingTrack) {
|
||||||
|
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;
|
||||||
@@ -212,7 +520,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
_shuffledIndex = prevIdx;
|
_shuffledIndex = prevIdx;
|
||||||
}
|
}
|
||||||
_currentIndex = _shuffledIndices[_shuffledIndex];
|
_currentIndex = _shuffledIndices[_shuffledIndex];
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +530,10 @@ class AudioService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
_currentIndex = prevIdx;
|
_currentIndex = prevIdx;
|
||||||
}
|
}
|
||||||
_playCurrent();
|
await _playCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 播放/暂停 ----
|
||||||
void togglePlay() {
|
void togglePlay() {
|
||||||
if (_currentSong == null) return;
|
if (_currentSong == null) return;
|
||||||
|
|
||||||
@@ -233,6 +542,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
PlaybackService().resume();
|
PlaybackService().resume();
|
||||||
}
|
}
|
||||||
|
// 保存状态(包括进度)
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopPlay() {
|
void stopPlay() {
|
||||||
@@ -243,11 +554,20 @@ class AudioService extends ChangeNotifier {
|
|||||||
bufferedNotifier.value = Duration.zero;
|
bufferedNotifier.value = Duration.zero;
|
||||||
_stopListening();
|
_stopListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
// 停止时也保存一次
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void seekTo(Duration position) {
|
void seekTo(Duration position) {
|
||||||
|
_isUserSeeking = true;
|
||||||
PlaybackService().seek(position);
|
PlaybackService().seek(position);
|
||||||
positionNotifier.value = position;
|
positionNotifier.value = position;
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 800), () {
|
||||||
|
_isUserSeeking = false;
|
||||||
|
});
|
||||||
|
// 拖动后保存进度
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
@@ -255,11 +575,11 @@ class AudioService extends ChangeNotifier {
|
|||||||
_currentIndex = -1;
|
_currentIndex = -1;
|
||||||
_shuffledIndices.clear();
|
_shuffledIndices.clear();
|
||||||
_shuffledIndex = -1;
|
_shuffledIndex = -1;
|
||||||
|
_currentPlaylistId = null;
|
||||||
stopPlay();
|
stopPlay();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 公开方法:供 PlayerPage 主动同步
|
|
||||||
void syncPlayerStateNow() {
|
void syncPlayerStateNow() {
|
||||||
final player = PlaybackService().player;
|
final player = PlaybackService().player;
|
||||||
final pos = player.state.position;
|
final pos = player.state.position;
|
||||||
@@ -269,7 +589,6 @@ class AudioService extends ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf');
|
'🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf');
|
||||||
|
|
||||||
// ⭐ 无条件更新(不要只判断 == 0)
|
|
||||||
if (pos.inMilliseconds >= 0) {
|
if (pos.inMilliseconds >= 0) {
|
||||||
positionNotifier.value = pos;
|
positionNotifier.value = pos;
|
||||||
}
|
}
|
||||||
@@ -291,7 +610,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 监听 ----
|
// ---- 监听 media_kit 状态 ----
|
||||||
void _startListening() {
|
void _startListening() {
|
||||||
if (_listening) return;
|
if (_listening) return;
|
||||||
_listening = true;
|
_listening = true;
|
||||||
@@ -303,12 +622,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_isPlaying != playing) {
|
if (_isPlaying != playing) {
|
||||||
_isPlaying = playing;
|
_isPlaying = playing;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
// 播放状态变化时保存进度(暂停时已保存,但播放开始也可保存一次)
|
||||||
|
if (playing) savePlaybackState();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
player.stream.position.listen((position) {
|
player.stream.position.listen((position) {
|
||||||
|
if (_isUserSeeking) {
|
||||||
|
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
||||||
|
return;
|
||||||
|
}
|
||||||
positionNotifier.value = position;
|
positionNotifier.value = position;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -328,8 +653,45 @@ class AudioService extends ChangeNotifier {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ⭐ 完整生命周期校验的 completed 监听
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
player.stream.completed.listen((_) {
|
player.stream.completed.listen((_) {
|
||||||
|
debugPrint(
|
||||||
|
'🎵 [completed event] RAW: index=$_currentIndex, isChangingTrack=$_isChangingTrack');
|
||||||
|
|
||||||
|
if (_isChangingTrack) {
|
||||||
|
debugPrint('⚠️ [completed] REJECTED: track switch in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_hasStartedCurrentPlayback) {
|
||||||
|
debugPrint('⚠️ [completed] REJECTED: playback never started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_handlingCompletion) {
|
||||||
|
debugPrint('⚠️ [completed] REJECTED: already handling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pos = player.state.position;
|
||||||
|
final dur = player.state.duration;
|
||||||
|
debugPrint('🎵 [completed] pos=$pos, dur=$dur');
|
||||||
|
|
||||||
|
if (dur.inMilliseconds <= 0) {
|
||||||
|
debugPrint('⚠️ [completed] REJECTED: invalid duration $dur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final diff = dur.inMilliseconds - pos.inMilliseconds;
|
||||||
|
debugPrint('🎵 [completed] diff=${diff}ms');
|
||||||
|
if (pos.inMilliseconds < dur.inMilliseconds - 800) {
|
||||||
|
debugPrint('⚠️ [completed] REJECTED: not at end, diff=${diff}ms');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ [completed] ACCEPTED: index=$_currentIndex');
|
||||||
|
_handlingCompletion = true;
|
||||||
_onPlaybackCompleted();
|
_onPlaybackCompleted();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -344,25 +706,173 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPlaybackCompleted() {
|
void _onPlaybackCompleted() {
|
||||||
if (_handlingCompletion) {
|
debugPrint(
|
||||||
debugPrint('⚠️ [service] completed ignored: already handling');
|
'🎵 [onPlaybackCompleted] ENTERED: index=$_currentIndex, total=${_queue.length}, mode=$_playMode');
|
||||||
|
|
||||||
|
if (_queue.isEmpty) {
|
||||||
|
debugPrint('⚠️ [onPlaybackCompleted] queue is empty, returning');
|
||||||
|
_handlingCompletion = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_handlingCompletion = true;
|
|
||||||
|
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [onPlaybackCompleted] invalid index: $_currentIndex, total=${_queue.length}');
|
||||||
|
_handlingCompletion = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isLastSong = _currentIndex + 1 >= _queue.length;
|
||||||
|
if (isLastSong && _playMode != PlayMode.repeatOne) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [onPlaybackCompleted] LAST SONG: index=$_currentIndex, mode=$_playMode, no next()');
|
||||||
|
_handlingCompletion = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🎵 [onPlaybackCompleted] will call next(), index=$_currentIndex');
|
||||||
try {
|
try {
|
||||||
if (_queue.isEmpty) return;
|
|
||||||
if (_playMode == PlayMode.repeatOne) {
|
if (_playMode == PlayMode.repeatOne) {
|
||||||
|
debugPrint(
|
||||||
|
'🎵 [onPlaybackCompleted] repeatOne mode, calling _playCurrent()');
|
||||||
_playCurrent();
|
_playCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debugPrint('🎵 [onPlaybackCompleted] calling next()');
|
||||||
next();
|
next();
|
||||||
} finally {
|
} finally {
|
||||||
_handlingCompletion = false;
|
_handlingCompletion = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 播放状态持久化
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// 保存当前播放状态
|
||||||
|
Future<void> savePlaybackState() async {
|
||||||
|
if (_queue.isEmpty) {
|
||||||
|
// 空队列时保存空状态
|
||||||
|
await _db.savePlaybackState(
|
||||||
|
queueJson: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
playMode: 'sequential',
|
||||||
|
positionMs: 0,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queueJson = _queue.map((song) => song.toJson()).toList();
|
||||||
|
final modeStr = _playMode == PlayMode.sequential
|
||||||
|
? 'sequential'
|
||||||
|
: _playMode == PlayMode.repeatOne
|
||||||
|
? 'repeat_one'
|
||||||
|
: 'shuffle';
|
||||||
|
await _db.savePlaybackState(
|
||||||
|
queueJson: queueJson,
|
||||||
|
currentIndex: _currentIndex,
|
||||||
|
playMode: modeStr,
|
||||||
|
currentPlaylistId: _currentPlaylistId,
|
||||||
|
positionMs: positionNotifier.value.inMilliseconds,
|
||||||
|
);
|
||||||
|
debugPrint('💾 [AudioService] playback state saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复播放状态
|
||||||
|
/// 恢复播放状态
|
||||||
|
Future<bool> restorePlaybackState() async {
|
||||||
|
final state = await _db.getPlaybackState();
|
||||||
|
if (state == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final queueJson = jsonDecode(state['queue_json'] as String) as List;
|
||||||
|
if (queueJson.isEmpty) return false;
|
||||||
|
|
||||||
|
final queue = queueJson
|
||||||
|
.map((item) => Song(
|
||||||
|
id: item['id'] as String,
|
||||||
|
title: item['title'] as String? ?? '',
|
||||||
|
artist: item['artist'] as String? ?? '未知艺术家',
|
||||||
|
url: item['url'] as String?,
|
||||||
|
artwork: null,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (queue.isEmpty) return false;
|
||||||
|
|
||||||
|
_queue = queue;
|
||||||
|
_currentIndex = state['current_index'] as int;
|
||||||
|
if (_currentIndex >= _queue.length) _currentIndex = 0;
|
||||||
|
|
||||||
|
final modeStr = state['play_mode'] as String? ?? 'sequential';
|
||||||
|
_playMode = modeStr == 'sequential'
|
||||||
|
? PlayMode.sequential
|
||||||
|
: modeStr == 'repeat_one'
|
||||||
|
? PlayMode.repeatOne
|
||||||
|
: PlayMode.shuffle;
|
||||||
|
_currentPlaylistId = state['current_playlist_id'] as String?;
|
||||||
|
|
||||||
|
// 保存待恢复的进度
|
||||||
|
final savedPos = state['position_ms'] as int? ?? 0;
|
||||||
|
_pendingSeekPosition = Duration(milliseconds: savedPos);
|
||||||
|
|
||||||
|
final song = _queue[_currentIndex];
|
||||||
|
_currentSong = song;
|
||||||
|
_onSongChanged?.call(song);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
|
||||||
|
|
||||||
|
// ⭐ 关键:加载音频但不自动播放
|
||||||
|
await _loadRestoredPlayback();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [AudioService] restore playback state failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⭐ 加载恢复的播放状态(加载音频,不自动播放)
|
||||||
|
Future<void> _loadRestoredPlayback() async {
|
||||||
|
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() {
|
||||||
|
// 先保存再清理
|
||||||
|
savePlaybackState();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
positionNotifier.dispose();
|
positionNotifier.dispose();
|
||||||
durationNotifier.dispose();
|
durationNotifier.dispose();
|
||||||
|
|||||||
@@ -2,20 +2,31 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
|
|
||||||
class PlayerService {
|
class PlayerService {
|
||||||
static final PlayerService _instance = PlayerService._internal();
|
static final PlayerService _instance = PlayerService._internal();
|
||||||
|
|
||||||
factory PlayerService() => _instance;
|
factory PlayerService() => _instance;
|
||||||
|
|
||||||
PlayerService._internal();
|
PlayerService._internal();
|
||||||
|
|
||||||
final Player _player = Player();
|
Player? _player;
|
||||||
|
|
||||||
void play(String url) {
|
Player get player {
|
||||||
_player.open(Media(url));
|
return _player ??= Player();
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause() => _player.pause();
|
void play(String url) {
|
||||||
void resume() => _player.play();
|
player.open(Media(url));
|
||||||
void stop() => _player.stop();
|
}
|
||||||
|
|
||||||
void dispose() => _player.dispose();
|
void pause() => player.pause();
|
||||||
|
|
||||||
|
void resume() => player.play();
|
||||||
|
|
||||||
|
void stop() => player.stop();
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_player?.dispose();
|
||||||
|
_player = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 状态监听...
|
// 状态监听...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// lib/utils/artwork_helper.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class ArtworkHelper {
|
||||||
|
static const String _artworkDir = 'artworks';
|
||||||
|
|
||||||
|
static Future<String?> saveArtwork(Uint8List data, String songKey) async {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final artworkDir = Directory('${dir.path}/$_artworkDir');
|
||||||
|
if (!await artworkDir.exists()) {
|
||||||
|
await artworkDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = '${artworkDir.path}/$songKey.jpg';
|
||||||
|
final file = File(path);
|
||||||
|
await file.writeAsBytes(data);
|
||||||
|
return path;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<File?> getArtwork(String songKey) async {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final path = '${dir.path}/$_artworkDir/$songKey.jpg';
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteArtwork(String songKey) async {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final path = '${dir.path}/$_artworkDir/$songKey.jpg';
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
debugPrint('🗑️ [ArtworkHelper] deleted artwork: $songKey');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// lib/utils/file_provider_utils.dart
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class FileProviderUtils {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.lxh.qingting_player/file_provider');
|
||||||
|
|
||||||
|
/// 生成 content:// URI(Android only)
|
||||||
|
static Future<Uri?> getContentUri(File file) async {
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
// 非 Android 平台直接返回 file:// URI
|
||||||
|
return Uri.file(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uriString = await _channel.invokeMethod('getContentUri', {
|
||||||
|
'path': file.path,
|
||||||
|
});
|
||||||
|
if (uriString is String && uriString.isNotEmpty) {
|
||||||
|
return Uri.parse(uriString);
|
||||||
|
}
|
||||||
|
return Uri.file(file.path);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('⚠️ [FileProviderUtils] PlatformException: ${e.message}');
|
||||||
|
return Uri.file(file.path);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [FileProviderUtils] Error: $e');
|
||||||
|
return Uri.file(file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查文件是否存在
|
||||||
|
static Future<bool> fileExists(String path) async {
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
return await file.exists();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除文件
|
||||||
|
static Future<void> deleteFile(String path) async {
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
@@ -2,20 +2,21 @@
|
|||||||
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 '../main.dart';
|
import '../utils/navigation.dart'; // ⭐ 导入 navigatorKey
|
||||||
|
|
||||||
class GlobalMiniPlayer extends StatelessWidget {
|
class GlobalMiniPlayer extends StatelessWidget {
|
||||||
const GlobalMiniPlayer({super.key});
|
const GlobalMiniPlayer({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// ⭐ 用 Selector 只监听 currentSong(低频变化)
|
|
||||||
final song = context.select<AudioService, Song?>((s) => s.currentSong);
|
final song = context.select<AudioService, Song?>((s) => s.currentSong);
|
||||||
// ⭐ 只监听播放状态(低频变化)
|
|
||||||
final isPlaying = context.select<AudioService, bool>((s) => s.isPlaying);
|
final isPlaying = context.select<AudioService, bool>((s) => s.isPlaying);
|
||||||
|
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
if (song == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@@ -24,7 +25,7 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 56 + bottomPadding,
|
height: 60 + bottomPadding,
|
||||||
color: const Color(0xFF1A1F1E),
|
color: const Color(0xFF1A1F1E),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -37,36 +38,43 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
bottom: Radius.circular(16),
|
bottom: Radius.circular(16),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 56,
|
height: 60,
|
||||||
color: const Color(0xFF1A1F1E),
|
color: const Color(0xFF1A1F1E),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (song != null) {
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
navigatorKey.currentState?.pushNamed('/player');
|
navigatorKey.currentState?.pushNamed('/player');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
highlightColor: Colors.white.withOpacity(0.05),
|
highlightColor: Colors.white.withValues(alpha: 0.05),
|
||||||
splashColor: Colors.white.withOpacity(0.1),
|
splashColor: Colors.white.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
width: 44,
|
||||||
height: 40,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: song != null
|
|
||||||
? const Color(0xFF2A3332)
|
|
||||||
: Colors.grey[800],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: const Color(0xFF2A3332),
|
||||||
|
image: song.artwork != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: MemoryImage(song.artwork!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: song.artwork == null
|
||||||
song != null ? Icons.music_note : Icons.music_off,
|
? const Icon(
|
||||||
color:
|
Icons.music_note,
|
||||||
song != null ? Colors.white38 : Colors.grey[600],
|
color: Colors.white38,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -75,7 +83,7 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
song != null ? song.title : '清听',
|
song.title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -86,12 +94,10 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
song != null ? song.artist : '未播放',
|
song.artist,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: song != null
|
color: Colors.grey[400],
|
||||||
? Colors.grey[400]
|
|
||||||
: Colors.grey[600],
|
|
||||||
decoration: TextDecoration.none,
|
decoration: TextDecoration.none,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -102,16 +108,12 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
song != null && isPlaying
|
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
? Icons.pause
|
color: Colors.white,
|
||||||
: Icons.play_arrow,
|
|
||||||
color: song != null ? Colors.white : Colors.grey[600],
|
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (song != null) {
|
|
||||||
context.read<AudioService>().togglePlay();
|
context.read<AudioService>().togglePlay();
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -121,9 +123,8 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (song != null) {
|
// ⭐ 使用 navigatorKey 直接跳转
|
||||||
navigatorKey.currentState?.pushNamed('/playlist');
|
navigatorKey.currentState?.pushNamed('/playlist');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -134,6 +135,7 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
android_intent_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: android_intent_plus
|
||||||
|
sha256: "2329378af63f49b985cb2e110ac784d08374f1e2b1984be77ba9325b1c8cce11"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "5.3.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -114,7 +122,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
@@ -276,10 +284,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -324,10 +332,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -396,10 +404,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -433,7 +441,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -441,7 +449,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
@@ -761,10 +769,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -801,10 +809,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+6
-2
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.4+1
|
version: 1.1.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.0.0
|
sdk: ^3.0.0
|
||||||
@@ -30,7 +30,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
media_kit: ^1.1.10
|
media_kit: 1.2.6
|
||||||
media_kit_libs_audio: ^1.0.7
|
media_kit_libs_audio: ^1.0.7
|
||||||
shared_preferences: 2.2.2
|
shared_preferences: 2.2.2
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
@@ -42,6 +42,10 @@ dependencies:
|
|||||||
flutter_cache_manager: ^3.3.1
|
flutter_cache_manager: ^3.3.1
|
||||||
sqflite: ^2.3.0
|
sqflite: ^2.3.0
|
||||||
audio_metadata_reader: ^1.7.1
|
audio_metadata_reader: ^1.7.1
|
||||||
|
path_provider: ^2.1.0
|
||||||
|
path: ^1.9.0
|
||||||
|
android_intent_plus: ^5.1.0
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
|||||||
+686
@@ -0,0 +1,686 @@
|
|||||||
|
你说得完全对。上一版确实是把 `frpc-console` 的文档骨架“迁移”过来了,而不是为清听重新设计的。现在我们来彻底扭转这个方向。
|
||||||
|
|
||||||
|
核心变化:**不再围绕“工程体系”组织文档,而是围绕“一首歌的生命路径”来组织**。每个文档回答一个具体的、用户视角的问题,而不是抽象的技术模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 清听 (QingTing) 文档体系(最终版)
|
||||||
|
|
||||||
|
### 📁 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
qingting/
|
||||||
|
│
|
||||||
|
├── README.md
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── getting-started.md
|
||||||
|
│ ├── architecture.md
|
||||||
|
│ ├── playback.md
|
||||||
|
│ ├── metadata.md
|
||||||
|
│ ├── android.md
|
||||||
|
│ ├── storage.md
|
||||||
|
│ └── decisions.md
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
├── android/
|
||||||
|
├── assets/
|
||||||
|
└── pubspec.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**只有 7 个文档 + 1 个 README。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📄 各文档定位
|
||||||
|
|
||||||
|
| 文档 | 回答的问题 | 风格 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **README.md** | 清听是什么?为什么做它?怎么快速体验? | 产品入口,克制,有信条 |
|
||||||
|
| **getting-started.md** | 怎么把代码跑起来? | 开发者上手,不含架构 |
|
||||||
|
| **architecture.md** | 整体怎么组织的?核心原则是什么? | 架构总览,哲学融入其中 |
|
||||||
|
| **playback.md** | **一首歌是怎么被播放出来的?** | **核心文档,完整链路** |
|
||||||
|
| **metadata.md** | 歌曲信息是怎么被认识的? | 诚实说明当前能力与局限 |
|
||||||
|
| **android.md** | 它怎么成为一个真正的 Android 播放器? | 平台能力,面向使用者 |
|
||||||
|
| **storage.md** | 远程数据和本地状态怎么共存? | 数据策略,区别于普通播放器 |
|
||||||
|
| **decisions.md** | 为什么最终选了这些方案? | 设计记录,每问三部分 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✍️ 各文档内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 1. `README.md` —— 入口
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 清听 QingTing
|
||||||
|
|
||||||
|
[](https://flutter.dev)
|
||||||
|
[](https://android.com)
|
||||||
|
|
||||||
|
**让远程音乐,像本地音乐一样可靠。**
|
||||||
|
|
||||||
|
清听是一个面向 Android 的 WebDAV 音乐播放器。
|
||||||
|
|
||||||
|
它解决的事情很简单:
|
||||||
|
|
||||||
|
> **让存储在 WebDAV 服务器上的音频文件,获得稳定、连续、可控的本地级播放体验。**
|
||||||
|
|
||||||
|
## ✨ 能力
|
||||||
|
|
||||||
|
- WebDAV 流式播放,无需下载
|
||||||
|
- 后台播放,退出应用不停
|
||||||
|
- 通知中心、锁屏、耳机控制
|
||||||
|
- 元数据读取与 SQLite 缓存
|
||||||
|
- 通知栏封面图
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆
|
||||||
|
git clone https://your-repo/qingting.git
|
||||||
|
cd qingting
|
||||||
|
|
||||||
|
# 获取依赖
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
详细步骤:[Getting Started →](./docs/getting-started.md)
|
||||||
|
|
||||||
|
## 📖 文档
|
||||||
|
|
||||||
|
- [整体架构](./docs/architecture.md) —— 系统怎么组织的
|
||||||
|
- [播放链路](./docs/playback.md) —— 一首歌从点击到喇叭
|
||||||
|
- [元数据](./docs/metadata.md) —— 歌曲信息怎么被认识
|
||||||
|
- [Android 集成](./docs/android.md) —— 怎么成为 Android 播放器
|
||||||
|
- [存储策略](./docs/storage.md) —— 远程与本地如何共存
|
||||||
|
- [设计决策](./docs/decisions.md) —— 为什么选了这些方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 最好的播放器,是当你开始听歌之后,就再也感觉不到它技术细节的那一个。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. `docs/architecture.md` —— 架构总览(哲学融入)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 整体架构
|
||||||
|
|
||||||
|
> *“代码定义它如何工作,架构定义它如何活着。”*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四条原则
|
||||||
|
|
||||||
|
清听的设计围绕四个原则展开,它们共同指向同一个目标:**播放体验的完整性**。
|
||||||
|
|
||||||
|
### 1. 播放优先
|
||||||
|
|
||||||
|
Metadata 识别失败,不能阻塞播放。
|
||||||
|
封面加载失败,不能阻塞播放。
|
||||||
|
数据库异常,不能阻塞播放。
|
||||||
|
|
||||||
|
播放是主路径,其他一切都是旁路。
|
||||||
|
|
||||||
|
### 2. 状态是事实,UI 只是观察者
|
||||||
|
|
||||||
|
播放状态只有一个事实源:`AudioService`。
|
||||||
|
|
||||||
|
UI、通知中心、锁屏都只观察这个状态,不拥有它。
|
||||||
|
|
||||||
|
### 3. 异步结果必须验证身份
|
||||||
|
|
||||||
|
切歌后,上一首歌的元数据请求如果才返回,必须丢弃。
|
||||||
|
|
||||||
|
通过 `_playbackGeneration` 为每次切歌分配唯一身份。
|
||||||
|
|
||||||
|
### 4. 平台边界显式接入
|
||||||
|
|
||||||
|
Android 特有功能(FileProvider、前台服务)通过 `MethodChannel` 显式调用,不依赖第三方插件的封装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分层结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ UI Layer │
|
||||||
|
│ PlayerPage / MiniPlayer / Playlist │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ AudioService │
|
||||||
|
│ 队列 · 状态 · 模式 · generation │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ AudioPlayerHandler │
|
||||||
|
│ MediaSession · PlaybackState · 系统回调│
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ MetadataService │
|
||||||
|
│ 读取 · 标准化 · 缓存 · 封面 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流向
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击歌曲
|
||||||
|
↓
|
||||||
|
设置队列 → 更新索引
|
||||||
|
↓
|
||||||
|
加载 WebDAV URL
|
||||||
|
↓
|
||||||
|
media_kit 播放
|
||||||
|
↓
|
||||||
|
发布 PlaybackState
|
||||||
|
↓
|
||||||
|
UI 更新 / 通知更新
|
||||||
|
```
|
||||||
|
|
||||||
|
元数据加载是旁路,不阻塞上述流程。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. `docs/playback.md` —— 播放链路(核心文档)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 播放链路
|
||||||
|
|
||||||
|
> *“一首歌从点击到喇叭出声,中间发生了什么?”*
|
||||||
|
|
||||||
|
这是清听最核心的文档。它描述的是整个播放系统如何协同工作,让用户获得连续、可靠的聆听体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
↓
|
||||||
|
Queue Management
|
||||||
|
↓
|
||||||
|
Current Index
|
||||||
|
↓
|
||||||
|
Generation (防漂移)
|
||||||
|
↓
|
||||||
|
WebDAV URL
|
||||||
|
↓
|
||||||
|
media_kit
|
||||||
|
↓
|
||||||
|
Playback State
|
||||||
|
↓
|
||||||
|
AudioService → UI / Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 队列与索引
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AudioService {
|
||||||
|
List<Song> _queue = [];
|
||||||
|
int _currentIndex = -1;
|
||||||
|
List<int> _shuffledIndices = [];
|
||||||
|
int _shuffledIndex = -1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `setQueue(queue, index)`:设置队列并开始播放
|
||||||
|
- `next()` / `previous()`:根据当前模式(顺序/随机/单曲循环)计算下一首
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generation 机制(防漂移)
|
||||||
|
|
||||||
|
这是清听处理异步竞态的关键设计。
|
||||||
|
|
||||||
|
```dart
|
||||||
|
int _playbackGeneration = 0;
|
||||||
|
|
||||||
|
void _playCurrent() {
|
||||||
|
_playbackGeneration++; // 每次切歌递增
|
||||||
|
_loadMetadataForCurrentSong(_playbackGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadMetadataForCurrentSong(int generation) async {
|
||||||
|
final data = await fetchMetadata();
|
||||||
|
if (_playbackGeneration != generation) return; // 丢弃过期结果
|
||||||
|
applyMetadata(data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景:**
|
||||||
|
|
||||||
|
```
|
||||||
|
歌曲 A → 请求元数据(gen=1)
|
||||||
|
用户切到歌曲 B → gen=2,请求元数据
|
||||||
|
歌曲 A 的元数据返回 → gen=1 ≠ 2 → 丢弃
|
||||||
|
歌曲 B 的元数据返回 → gen=2 = 2 → 应用
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PlaybackState 发布
|
||||||
|
|
||||||
|
```dart
|
||||||
|
playbackState.add(PlaybackState(
|
||||||
|
controls: [skipToPrevious, playPause, skipToNext],
|
||||||
|
playing: playing,
|
||||||
|
position: _currentPosition,
|
||||||
|
updateTime: DateTime.now(),
|
||||||
|
systemActions: {MediaAction.seek},
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
- `systemActions` 必须包含 `seek`,通知栏进度条才能拖动
|
||||||
|
- 使用 `playPause` 而非分离的 `play`/`pause`
|
||||||
|
- 每 500ms 节流发布,平衡性能与流畅度
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整时序
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant UI
|
||||||
|
participant AudioService
|
||||||
|
participant media_kit
|
||||||
|
participant MetadataService
|
||||||
|
|
||||||
|
User->>UI: 点击歌曲
|
||||||
|
UI->>AudioService: setQueue(queue, index)
|
||||||
|
AudioService->>AudioService: _currentIndex = index
|
||||||
|
AudioService->>AudioService: _playbackGeneration++
|
||||||
|
AudioService->>media_kit: 加载 WebDAV URL
|
||||||
|
media_kit-->>AudioService: 开始播放
|
||||||
|
AudioService->>AudioService: 发布 PlaybackState
|
||||||
|
AudioService-->>UI: 状态更新
|
||||||
|
|
||||||
|
par 旁路加载
|
||||||
|
AudioService->>MetadataService: 请求元数据(携带 generation)
|
||||||
|
MetadataService-->>AudioService: 返回
|
||||||
|
AudioService->>AudioService: 校验 generation
|
||||||
|
AudioService-->>UI: 更新歌曲信息
|
||||||
|
end
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. `docs/metadata.md` —— 元数据
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 元数据
|
||||||
|
|
||||||
|
> *“歌曲信息是怎么被认识的?”*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
```
|
||||||
|
WebDAV 音频文件
|
||||||
|
│
|
||||||
|
├── ID3 标签 (MP3)
|
||||||
|
├── Vorbis 注释 (OGG/FLAC)
|
||||||
|
└── MP4 原子 (M4A)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 读取流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 检查内存缓存 → 命中则返回
|
||||||
|
2. 检查 SQLite → 命中则反序列化
|
||||||
|
3. 下载文件头部 (~1MB) 到临时目录
|
||||||
|
4. audio_metadata_reader 提取标签
|
||||||
|
5. MetadataNormalizer 标准化
|
||||||
|
6. 字段缺失时 fallback 到文件名/路径
|
||||||
|
7. 写入 SQLite 和内存缓存
|
||||||
|
8. 异步提取封面图
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 标准化与 Fallback
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MetadataNormalizer {
|
||||||
|
String normalizeTitle(String? tag, String filename) {
|
||||||
|
return tag?.isNotEmpty == true ? tag! : _extractFromFilename(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizeArtist(String? tag, String? album, String path) {
|
||||||
|
return tag?.isNotEmpty == true ? tag! : _extractFromPath(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:**标签 > 文件名 > 路径推断**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 封面图处理
|
||||||
|
|
||||||
|
```
|
||||||
|
artwork bytes (内存)
|
||||||
|
↓
|
||||||
|
保存到 /data/.../artworks/{md5(id)}.jpg
|
||||||
|
↓
|
||||||
|
通过 MethodChannel 调用 FileProvider
|
||||||
|
↓
|
||||||
|
content:// URI
|
||||||
|
↓
|
||||||
|
MediaItem.artUri → 通知中心
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前状态与未来
|
||||||
|
|
||||||
|
**已实现:**
|
||||||
|
- 一次读取 + fallback
|
||||||
|
- SQLite 持久化
|
||||||
|
- 封面图提取与推送
|
||||||
|
|
||||||
|
**计划中(渐进式识别):**
|
||||||
|
- 5% 快速探测 (title/artist)
|
||||||
|
- 70% 完整探测 (album/genre/year)
|
||||||
|
- 100% 最终确认
|
||||||
|
- 置信度与校验次数记录
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. `docs/android.md` —— Android 集成
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Android 集成
|
||||||
|
|
||||||
|
> *“它怎么成为一个真正的 Android 播放器?”*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与系统的关系
|
||||||
|
|
||||||
|
```
|
||||||
|
Flutter (Dart)
|
||||||
|
│
|
||||||
|
├── audio_service (桥接层)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Android MediaSession
|
||||||
|
│
|
||||||
|
├── 通知中心 (播放/暂停/上一首/下一首/进度条)
|
||||||
|
├── 锁屏控制
|
||||||
|
├── 耳机按钮 (播放/暂停/切歌)
|
||||||
|
└── Android Auto (预留)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MediaSession 契约
|
||||||
|
|
||||||
|
清听遵循 Android 媒体应用的契约规范:
|
||||||
|
|
||||||
|
| 要求 | 清听实现 |
|
||||||
|
|:---|:---|
|
||||||
|
| 声明 `MediaAction.seek` | `systemActions` 中包含 |
|
||||||
|
| 使用 `playPause` 统一控制 | 不分离 play/pause |
|
||||||
|
| 正确更新 `updateTime` | 每次发布同步更新 |
|
||||||
|
| 前台服务 | 播放时启动 Foreground Service |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通知中心控制
|
||||||
|
|
||||||
|
| 控制 | 实现 |
|
||||||
|
|:---|:---|
|
||||||
|
| 播放/暂停 | `MediaAction.playPause` → `click(MediaButton.media)` |
|
||||||
|
| 上一首/下一首 | `MediaAction.skipToPrevious/Next` |
|
||||||
|
| 进度拖动 | `systemActions: {seek}` + `Handler.seek()` |
|
||||||
|
| 封面图 | `MediaItem.artUri = content://...` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FileProvider(封面图推送)
|
||||||
|
|
||||||
|
Android 10+ 禁止 `file://` 跨应用传递。
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter_file_provider (不采用)
|
||||||
|
↓
|
||||||
|
MethodChannel: com.lxh.qingting_player/file_provider
|
||||||
|
↓
|
||||||
|
Kotlin: FileProvider.getUriForFile()
|
||||||
|
↓
|
||||||
|
content:// URI
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不用插件?** 维护状态不确定,兼容性问题。显式边界更可控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台播放
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await AudioService.start(
|
||||||
|
backgroundTaskEntrypoint: _audioPlayerTaskEntrypoint,
|
||||||
|
androidNotificationOngoing: false,
|
||||||
|
androidStopForegroundOnPause: false,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ongoing: false`:暂停时不显示“正在运行”
|
||||||
|
- `stopForegroundOnPause: false`:暂停时仍保留前台状态,快速恢复
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. `docs/storage.md` —— 存储策略
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 存储策略
|
||||||
|
|
||||||
|
> *“远程数据和本地状态怎么共存?”*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两类数据
|
||||||
|
|
||||||
|
```
|
||||||
|
远程数据 (WebDAV)
|
||||||
|
│
|
||||||
|
├── 音频文件 (流式播放,不缓存)
|
||||||
|
└── 目录结构 (浏览用)
|
||||||
|
│
|
||||||
|
本地数据 (设备存储)
|
||||||
|
│
|
||||||
|
├── 元数据 (SQLite)
|
||||||
|
├── 封面图 (文件系统)
|
||||||
|
└── 播放状态 (内存)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么不缓存音频文件?
|
||||||
|
|
||||||
|
- WebDAV 存储通常较大,本地存储有限
|
||||||
|
- 流式播放已足够,无需额外下载
|
||||||
|
- 版权/存储空间考量
|
||||||
|
|
||||||
|
> **只缓存“关于歌曲的信息”,不缓存歌曲本身。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SQLite 表结构
|
||||||
|
|
||||||
|
### songs
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|:---|:---|
|
||||||
|
| `song_key` | WebDAV 路径 (唯一标识) |
|
||||||
|
| `title` | 歌曲标题 |
|
||||||
|
| `artist` | 艺术家 |
|
||||||
|
| `album` | 专辑 |
|
||||||
|
| `artwork_path` | 封面图本地路径 |
|
||||||
|
| `confidence` | 置信度 (预留) |
|
||||||
|
| `validation_count` | 校验次数 (预留) |
|
||||||
|
|
||||||
|
### metadata_cache
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|:---|:---|
|
||||||
|
| `song_key` | 关联 songs |
|
||||||
|
| `cache_path` | 缓存文件路径 (预留) |
|
||||||
|
| `cache_created_at` | 创建时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 缓存策略
|
||||||
|
|
||||||
|
| 数据 | 缓存位置 | 有效期 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 元数据 | SQLite | 永久 (除非 WebDAV 文件变更) |
|
||||||
|
| 封面图 | 文件系统 | 永久 |
|
||||||
|
| 播放进度 | 内存 | 会话级 |
|
||||||
|
| 队列 | 内存 | 会话级 |
|
||||||
|
|
||||||
|
> 元数据缓存的失效策略:当前以 `song_key` (WebDAV 路径) 为唯一标识,文件变更后重新读取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件目录
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/data/com.lxh.qingting_player/
|
||||||
|
├── app_flutter/
|
||||||
|
│ ├── artworks/
|
||||||
|
│ │ └── {md5(id)}.jpg
|
||||||
|
│ └── temp/
|
||||||
|
│ └── metadata_*.tmp
|
||||||
|
└── databases/
|
||||||
|
└── qingting.db
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. `docs/decisions.md` —— 设计决策
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 设计决策
|
||||||
|
|
||||||
|
> *“为什么最终选了这些方案?”*
|
||||||
|
|
||||||
|
每个决策三段:**问题 → 决策 → 原因**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么用 media_kit 而不是 just_audio?
|
||||||
|
|
||||||
|
**问题:** 需要稳定支持 WebDAV 流式播放和 MediaSession 集成。
|
||||||
|
|
||||||
|
**决策:** 选用 `media_kit`。
|
||||||
|
|
||||||
|
**原因:** `media_kit` 基于 libmpv,对流式 HTTP 支持更好,与 `audio_service` 集成更紧密。`just_audio` 虽社区更大,但对 MediaSession 的支持需要额外桥接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么用 playPause 而非 play / pause 分离?
|
||||||
|
|
||||||
|
**问题:** 通知中心按钮状态偶尔错乱。
|
||||||
|
|
||||||
|
**决策:** 统一使用 `MediaAction.playPause`。
|
||||||
|
|
||||||
|
**原因:** Android MediaSession 对分离 action 的映射有特定逻辑,`playPause` 是系统原生支持的统一控制方式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么不用 flutter_file_provider?
|
||||||
|
|
||||||
|
**问题:** 需要向通知中心传递 `content://` URI。
|
||||||
|
|
||||||
|
**决策:** 通过 `MethodChannel` 调用 Kotlin 侧原生 `FileProvider`。
|
||||||
|
|
||||||
|
**原因:** `flutter_file_provider` 插件维护状态不确定,Android 版本兼容性问题。显式平台边界更可控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么引入 generation 机制?
|
||||||
|
|
||||||
|
**问题:** 切歌后,上一首歌的元数据返回会覆盖当前歌曲信息。
|
||||||
|
|
||||||
|
**决策:** 每次切歌递增 `_playbackGeneration`,异步回调校验身份。
|
||||||
|
|
||||||
|
**原因:** 防御性编程,处理异步竞态。这是清听保证 UI 状态永远属于当前歌曲的关键机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么用 SQLite?
|
||||||
|
|
||||||
|
**问题:** 需要持久化元数据,支持快速检索。
|
||||||
|
|
||||||
|
**决策:** 使用 `sqflite`。
|
||||||
|
|
||||||
|
**原因:** 边缘设备不需要 PostgreSQL;单文件备份复制即迁移;零配置零维护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么进度更新节流到 500ms?
|
||||||
|
|
||||||
|
**问题:** 进度回调 (100ms/次) 导致频繁 UI 重建和通知刷新。
|
||||||
|
|
||||||
|
**决策:** 每 500ms 发布一次进度变化。
|
||||||
|
|
||||||
|
**原因:** 人眼对 500ms 的进度变化已足够流畅,在性能和体验之间取得平衡。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 这套文档体系的特征
|
||||||
|
|
||||||
|
1. **沿着“一首歌的生命路径”组织** —— 从点击、播放、元数据、Android 集成到存储,自然连贯
|
||||||
|
2. **哲学融入架构,不单独成立文件** —— `architecture.md` 第一节讲原则,不做独立的 `philosophy.md`
|
||||||
|
3. **`playback.md` 是绝对核心** —— 清听最有价值的文档,讲述完整链路
|
||||||
|
4. **诚实区分已实现和计划中** —— `metadata.md` 明确标注当前状态与未来
|
||||||
|
5. **`decisions.md` 保持简洁** —— 每问三部分:问题 → 决策 → 原因,不加 ADR 编号
|
||||||
|
6. **没有“文档体系”的仪式感** —— 只有 7 个文档,每个回答一个具体问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 与 frpc-console 文档的差异
|
||||||
|
|
||||||
|
| 方面 | frpc-console | 清听 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 核心主线 | 部署与长期运行 | 播放链路 |
|
||||||
|
| 关键文档 | `deployment-engine.md` | `playback.md` |
|
||||||
|
| 哲学位置 | 独立 `philosophy.md` | 融入 `architecture.md` |
|
||||||
|
| 平台文档 | `edge-node.md` (硬件边界) | `android.md` (系统边界) |
|
||||||
|
| 文档数量 | 10+ | 7 |
|
||||||
|
| 文档命名 | 抽象命名 (engineering-philosophy) | 直白命名 (playback, metadata) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **frpc-console**:这是一个系统应该如何被长期运行。
|
||||||
|
>
|
||||||
|
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
|
||||||
|
|
||||||
|
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。
|
||||||
Reference in New Issue
Block a user