Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 +1,7 @@
|
|||||||
<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" />
|
||||||
@@ -8,44 +9,43 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<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 +54,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 +72,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>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,15 +3,73 @@ package com.lxh.qingting_player
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity // ⭐ 改用这个
|
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
|
||||||
|
|
||||||
class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivity
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
private val CHANNEL = "com.lxh.qingting_player/file_provider"
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// ⭐ 强制加载 libmediakitandroidhelper.so,触发 JNI_OnLoad,缓存 JavaVM
|
||||||
|
try {
|
||||||
|
System.loadLibrary("mediakitandroidhelper")
|
||||||
|
Log.d("MainActivity", "✅ libmediakitandroidhelper loaded")
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
Log.e("MainActivity", "❌ libmediakitandroidhelper load failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ 可选:预加载 libmpv.so(让系统 linker 也可见,增强兼容性)
|
||||||
|
try {
|
||||||
|
System.loadLibrary("mpv")
|
||||||
|
Log.d("MainActivity", "✅ libmpv loaded")
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
Log.e("MainActivity", "❌ libmpv load failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setHighRefreshRate()
|
setHighRefreshRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
setHighRefreshRate()
|
setHighRefreshRate()
|
||||||
@@ -29,17 +87,4 @@ class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivit
|
|||||||
Log.e("QTPlayer", "设置高刷失败: ${e.message}")
|
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.2.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-84
@@ -1,3 +1,4 @@
|
|||||||
|
// 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';
|
||||||
@@ -18,21 +19,16 @@ import 'widgets/global_mini_player.dart';
|
|||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
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 +37,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 +132,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 +149,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 +177,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 +227,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 +234,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 +242,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 +250,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 +268,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,42 +285,84 @@ 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');
|
||||||
|
|
||||||
// 监听歌曲变化,更新通知栏
|
|
||||||
// main.dart 中 build 方法里的这部分
|
|
||||||
final audioService = context.watch<AudioService>();
|
|
||||||
final handler = context.read<AudioPlayerHandler>();
|
|
||||||
final song = audioService.currentSong;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (song != null) {
|
|
||||||
// ⭐ song 不为空时才调用
|
|
||||||
handler.updateNotification(
|
|
||||||
id: song.id,
|
|
||||||
title: song.title,
|
|
||||||
artist: song.artist,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 清空通知
|
|
||||||
handler.mediaItem.add(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '清听',
|
title: '清听',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
@@ -330,11 +378,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,12 +387,35 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,20 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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 +24,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 +37,45 @@ 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) {
|
// ⭐ 使用 Navigator.of(context) 替代 navigatorKey
|
||||||
navigatorKey.currentState?.pushNamed('/player');
|
Navigator.of(context, rootNavigator: true)
|
||||||
}
|
.pushNamed('/player');
|
||||||
},
|
},
|
||||||
highlightColor: Colors.white.withOpacity(0.05),
|
// ⭐ 替换弃用的 withOpacity 为 withValues
|
||||||
splashColor: Colors.white.withOpacity(0.1),
|
highlightColor: Colors.white.withValues(alpha: 0.05),
|
||||||
|
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 +84,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 +95,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 +109,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 +124,8 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (song != null) {
|
// ⭐ 使用 Navigator.of(context)
|
||||||
navigatorKey.currentState?.pushNamed('/playlist');
|
Navigator.of(context).pushNamed('/playlist');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -134,6 +136,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:
|
||||||
|
|||||||
+7
-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,8 +30,9 @@ 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
|
||||||
|
#media_kit_libs_android_audio: 1.3.8
|
||||||
shared_preferences: 2.2.2
|
shared_preferences: 2.2.2
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
dio: ^5.4.0 # HTTP 客户端
|
dio: ^5.4.0 # HTTP 客户端
|
||||||
@@ -42,6 +43,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