44 Commits
Author SHA1 Message Date
lxh2875931338 1aba004e43 定住这个commit,切换至test分支进行部分破坏性变更:涉及到app内多实例异常问题修复 2026-09-02 21:17:59 +08:00
lxh2875931338 cdf77b308d 开始给原先适配期的状态擦屁股:现在是修复播放页与播放列表页的显示 2026-09-02 20:44:46 +08:00
lxh2875931338 200067a3b2 回归GPL主线的第一版本,后续LGPL仍会继续测试,但是将不会在主分支加入测试 2026-09-02 20:23:06 +08:00
lxh2875931338 974dee2995 revert 99917bac87
revert Core Freeze:核心链路功能已经完成设计,后续进入beta功能开发模式,接下来会在UI部分微调后,进入首个Release版本(1.1.0-Release)
2026-09-02 10:15:54 +08:00
lxh2875931338 b86a5d2b46 准备进入新的测试阶段,先做一个snapshot(此版本下不可用) 2026-08-31 20:51:09 +08:00
lxh2875931338 eb46c53f9a 不考虑上架问题,确认此次更新 2026-08-31 00:05:13 +08:00
lxh2875931338 53f60d393f Merge remote-tracking branch 'origin/test' 2026-08-30 23:57:44 +08:00
lxh2875931338 08ea8a4508 test通道确认功能完整
libmpv.so(LGPL版本)
SHA256 = E56776A1BA03892C9A037A534A7114BA54F6EE202083A706CF878924BAA9D7AA
2026-08-30 23:57:24 +08:00
lxh2875931338 abffb77167 主通道同步进行更新版本号 2026-08-30 21:56:32 +08:00
lxh2875931338 21a1ad1d05 确定这次重构的版本号 2026-08-30 21:55:51 +08:00
lxh2875931338 02ca590c65 LGPL库引入后导致app整体处于全面重适配状态,将会处于长期不可用状态 2026-08-30 21:51:19 +08:00
lxh2875931338 e102e07378 更新一套LGPL版核心库,进入底层变更适配期 2026-08-30 20:53:52 +08:00
lxh2875931338 99917bac87 Core Freeze:核心链路功能已经完成设计,后续进入beta功能开发模式,接下来会在UI部分微调后,进入首个Release版本(1.1.0-Release) 2026-08-28 22:59:04 +08:00
lxh2875931338 86b1d0df42 正在播放的自滑动刹车动画动作优化完成,同时调整播放页进入动作 2026-08-28 22:45:14 +08:00
lxh2875931338 2e55f94df2 优化正在播放的自滑动刹车动画动作中 2026-08-28 22:27:44 +08:00
lxh2875931338 fa396ce681 播放列表持久化设计、播放列表点击显示当前播放歌曲的列表内位置 2026-08-28 22:16:55 +08:00
lxh2875931338 f91846be9a 优化下一曲检测时长,窗口放宽到800ms 2026-08-28 20:52:03 +08:00
lxh2875931338 bb74cd1351 顺序播放跳曲与首次启动跳曲问题的metadata部分修复完成,测试已通过 2026-08-27 23:27:05 +08:00
lxh2875931338 6cb1167a21 顺序播放跳曲与首次启动跳曲问题已部分修复,播放逻辑正确,metadata部分仍需修复 2026-08-27 23:18:40 +08:00
lxh2875931338 9fec9b98c5 现在正在修复首次启动点击跳曲问题与顺序播放的跳曲问题 2026-08-27 22:54:18 +08:00
lxh2875931338 2a29e30940 修复表写入问题,同时增加对读库动作的log可视化 2026-08-27 22:32:38 +08:00
lxh2875931338 e58be9c17f 引入播放列表库,同时引入全新的播放控制器,以控制读库与播放模式的动作,但是功能仍在完善,需要确认部分边界问题 2026-08-27 22:29:20 +08:00
lxh2875931338 ad6617592e 修复webdav重新登录后,userID位置错位与页面跳转逻辑错误的重大bug 2026-08-27 22:04:59 +08:00
lxh2875931338 bca1288bd1 追加webdav重新登录的button,还在测试边界情况 2026-08-27 21:52:45 +08:00
lxh2875931338 90aa7d10e9 现在列表底部的信息显示正常了 2026-08-27 21:42:04 +08:00
lxh2875931338 055df89ca5 初步敲定一个readme,后续继续推进功能性设计,直到认为功能基本达成了,readme再继续设计完善 2026-08-26 22:17:39 +08:00
lxh2875931338 8f3f827429 优化部分功能逻辑:元数据信息漂移
优化部分体验问题,包含动画、界面层级等
2026-08-26 21:13:09 +08:00
lxh2875931338 94d37fe263 去掉部分高频刷新的log 2026-08-25 23:14:39 +08:00
lxh2875931338 f2b4d1f46d 通知中心封面图显示
部分界面的信息更新
缓存系统初步引入
即将进入播放列表更新
2026-08-25 23:09:17 +08:00
lxh2875931338 0e8921742c 引入部分缓存机制
引入封面图信息

正在进入测试阶段
2026-08-25 22:13:36 +08:00
lxh2875931338 7bd23bfee6 metadata读取能力已达成,接下来会顺着路径完成部分缓存指针设计 2026-08-25 21:57:04 +08:00
lxh2875931338 b75ea9e4ce 现在版本下,通知中心的进度条拖动seek能力边界问题已全部修复 2026-08-24 23:49:32 +08:00
lxh2875931338 bb466ca534 修复通知中心暂停后播放进度归0的问题 2026-08-24 23:00:45 +08:00
lxh2875931338 35be847ce7 Merge branch 'test' 2026-08-23 21:42:48 +08:00
lxh2875931338 f6eb1b108a 现在播放页的实时效果有了 2026-08-23 21:42:37 +08:00
lxh2875931338 d593ce05a1 现在的问题比较大,因为metadata的引入导致原有的部分播放进度信息推送错误 2026-08-23 20:29:31 +08:00
lxh2875931338 9a60441e04 基础实现代码框架先上,后面慢慢改逻辑 2026-08-23 19:24:16 +08:00
lxh2875931338 6c4e77ca00 开发板分支同步更新版本号 2026-08-23 19:08:58 +08:00
lxh2875931338 7a813dc609 正式版版本号更新 2026-08-23 19:08:37 +08:00
lxh2875931338 d49349f7a7 准备尝试metadata设计 2026-08-23 19:08:08 +08:00
lxh2875931338 c3271b07f1 后台连续播放 + MediaItem + 播放状态 + 实时进度 + 系统 seek + seek 后继续实时更新均已正常达成目标 2026-08-23 19:03:49 +08:00
lxh2875931338 be49823b60 播放进度更新已达成,准备优化通知中心的进度条拖动点击 2026-08-23 18:58:43 +08:00
lxh2875931338 1a07664f62 当前固定audio_service为0.18.19,同时已优化媒体信息推送的部分能力,尚在进行媒体信息-播放进度的更新 2026-08-23 18:21:49 +08:00
lxh2875931338 4ebc0e3f8c 版本号变更,即将进入新的优化部分 2026-08-23 18:05:44 +08:00
41 changed files with 4225 additions and 639 deletions
+43 -11
View File
@@ -1,17 +1,49 @@
# qt_player
# 清听 QingTing
A new Flutter project.
[![Flutter](https://img.shields.io/badge/Flutter-3.x-blue)](https://flutter.dev)
[![Platform](https://img.shields.io/badge/Platform-Android-green)](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)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
> **让存储在 WebDAV 服务器上的音频文件,获得稳定、连续、可控的本地级播放体验。**
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) —— 为什么选了这些方案
---
> 最好的播放器,是当你开始听歌之后,就再也感觉不到它技术细节的那一个。
+4
View File
@@ -7,6 +7,10 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
include: package:flutter_lints/flutter.yaml
linter:
+1
View File
@@ -15,3 +15,4 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
.settings
+32 -5
View File
@@ -1,12 +1,11 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.lxh.qingting_player"
compileSdk = flutter.compileSdkVersion
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -17,19 +16,47 @@ android {
defaultConfig {
applicationId = "com.lxh.qingting_player"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
targetSdk = 34
versionCode = flutter.versionCode
versionName = flutter.versionName
// ⬇️ 把 ndk 配置放在这里(defaultConfig 内部)
// 仅打包 arm64-v8a
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 {
debug {
}
release {
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
+45
View File
@@ -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();
}
+30 -17
View File
@@ -1,51 +1,52 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 权限不变 -->
<uses-permission android:name="android.permission.INTERNET"/>
<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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:label="清听"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="清听"
android:usesCleartextTraffic="true"
android:foregroundServiceType="mediaPlayback">
android:foregroundServiceType="mediaPlayback"
android:extractNativeLibs="true"> <!-- ⭐ 强制提取 .so,避免压缩后加载失败 -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- ⭐ 关键:AudioService 必须同时声明 MediaBrowserService 和 MEDIA_BUTTON -->
<!-- AudioService -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<!-- 媒体浏览器服务(MediaButtonReceiver 需要) -->
<action android:name="android.media.browse.MediaBrowserService" />
<!-- 媒体按钮(通知栏、蓝牙、耳机按键) -->
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<!-- MediaButtonReceiver 接收系统媒体按钮事件 -->
<!-- MediaButtonReceiver -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
@@ -54,6 +55,17 @@
</intent-filter>
</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
android:name="flutterEmbedding"
android:value="2" />
@@ -61,8 +73,9 @@
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,115 @@
package com.lxh.qingting_player
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import android.content.Context
import android.media.AudioManager
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.lxh.qingting_player/file_provider"
override fun onCreate(savedInstanceState: Bundle?) {
// ⭐ 强制加载 libmediakitandroidhelper.so,触发 JNI_OnLoad,缓存 JavaVM
try {
System.loadLibrary("mediakitandroidhelper")
Log.d("MainActivity", "✅ libmediakitandroidhelper loaded")
} catch (e: UnsatisfiedLinkError) {
Log.e("MainActivity", "❌ libmediakitandroidhelper load failed", e)
}
// ⭐ 可选:预加载 libmpv.so(让系统 linker 也可见,增强兼容性)
try {
System.loadLibrary("mpv")
Log.d("MainActivity", "✅ libmpv loaded")
} catch (e: UnsatisfiedLinkError) {
Log.e("MainActivity", "❌ libmpv load failed", e)
}
super.onCreate(savedInstanceState)
requestAudioFocus()
setHighRefreshRate()
}
private fun requestAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager.requestAudioFocus(
AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.d("MainActivity", "音频焦点变化: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
null,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
Log.d("MainActivity", "✅ 已请求音频焦点")
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// FileProvider MethodChannel for sharing artwork via content:// URI
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getContentUri") {
val path = call.argument<String>("path")
if (path != null && path.isNotEmpty()) {
try {
val file = File(path)
if (!file.exists()) {
result.error("FILE_NOT_FOUND", "File does not exist: $path", null)
return@setMethodCallHandler
}
val uri = FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
file
)
Log.d("QTPlayer", "📢 [FileProvider] content URI: $uri")
result.success(uri.toString())
} catch (e: Exception) {
Log.e("QTPlayer", "❌ [FileProvider] error: ${e.message}")
result.error("ERROR", e.message, null)
}
} else {
result.error("INVALID_PATH", "path is null or empty", null)
}
} else {
result.notImplemented()
}
}
}
override fun onResume() {
super.onResume()
setHighRefreshRate()
}
private fun setHighRefreshRate() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val params = window.attributes
params.preferredRefreshRate = 120f
window.attributes = params
Log.d("QTPlayer", "✅ 设置高刷 120Hz")
}
} catch (e: Exception) {
Log.e("QTPlayer", "设置高刷失败: ${e.message}")
}
}
}
@@ -1,45 +0,0 @@
package com.lxh.qingting_player
import android.os.Build
import android.os.Bundle
import android.util.Log
import com.ryanheise.audioservice.AudioServiceActivity // ⭐ 改用这个
class MainActivity : AudioServiceActivity() { // ⭐ 继承 AudioServiceActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHighRefreshRate()
}
override fun onResume() {
super.onResume()
setHighRefreshRate()
}
private fun setHighRefreshRate() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val params = window.attributes
params.preferredRefreshRate = 120f
window.attributes = params
Log.d("QTPlayer", "✅ 设置高刷 120Hz")
}
} catch (e: Exception) {
Log.e("QTPlayer", "设置高刷失败: ${e.message}")
}
}
private fun clearRefreshRate() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val params = window.attributes
params.preferredRefreshRate = 0f
window.attributes = params
Log.d("QTPlayer", "恢复默认刷新率")
}
} catch (e: Exception) {
Log.e("QTPlayer", "恢复默认刷新率失败: ${e.message}")
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- app_flutter 目录(getApplicationDocumentsDirectory -->
<files-path name="app_flutter" path="../app_flutter/" />
<!-- 封面图目录 -->
<files-path name="artworks" path="../app_flutter/artworks/" />
<!-- 缓存目录 -->
<cache-path name="cache" path="." />
<!-- 外部文件目录(备用) -->
<external-files-path name="external" path="." />
</paths>
+1 -1
View File
@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
-1
View File
@@ -1,5 +1,4 @@
// lib/audio/player_controller.dart
import 'package:media_kit/media_kit.dart';
import '../services/playback_service.dart'; // ⭐ 修正路径
class PlayerController {
+63
View File
@@ -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),
),
);
}
}
+5
View File
@@ -0,0 +1,5 @@
// lib/constants/ui_constants.dart
class UIConstants {
static const double miniPlayerHeight = 60;
static const double miniPlayerBottomSpace = 80; // 60 + 20 安全余量
}
+144
View File
@@ -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;
}
+504
View File
@@ -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;
}
}
+171 -85
View File
@@ -1,9 +1,11 @@
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:media_kit/media_kit.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter/services.dart';
import 'services/audio_service.dart';
import 'services/playback_service.dart';
import 'services/app_lifecycle_service.dart';
@@ -14,25 +16,20 @@ import 'pages/home_page.dart';
import 'pages/player_page.dart';
import 'pages/playlist_page.dart';
import 'widgets/global_mini_player.dart';
import 'utils/navigation.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final stopwatch = Stopwatch();
// ⭐ 防止 Hot Restart / 热重载时重复初始化
bool _appInitialized = false;
// ⭐ 全局缓存 Handler
AudioPlayerHandler? _audioHandler;
// ⭐ 全局缓存 LifecycleManager
final AppLifecycleManager lifecycleManager = AppLifecycleManager();
void main() async {
// ⭐ P0:防止重复初始化
if (_appInitialized) {
debugPrint('⏱️ main 已初始化,跳过重复执行');
return;
}
_appInitialized = true;
stopwatch.start();
@@ -41,33 +38,71 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
debugPrint('⏱️ T0.5 ensureInitialized: ${stopwatch.elapsedMilliseconds}ms');
// ⭐ MediaKit 在 runApp 前初始化(官方要求)
debugPrint('⏱️ T0.55 before MediaKit.ensureInitialized');
try {
MediaKit.ensureInitialized();
debugPrint('⏱️ T0.6 MediaKit 初始化完成');
} catch (e) {
debugPrint('❌ MediaKit 初始化失败: $e');
debugPrint('⏱️ T0.6 MediaKit.ensureInitialized returned');
} catch (e, st) {
debugPrint('❌ MediaKit.ensureInitialized failed: $e');
debugPrint('$st');
rethrow;
}
// ⭐ 创建 HandlerAudioService 稍后初始化)
debugPrint('⏱️ T0.61 before AudioPlayerHandler');
try {
_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) {
debugPrint(
'📢 [main] song.artwork is '
'${song.artwork != null ? 'not null' : 'null'}',
);
_audioHandler!.updateNotification(
id: song.id,
title: song.title,
artist: song.artist,
artwork: song.artwork,
);
});
debugPrint('⏱️ T0.7 before runApp');
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AudioService()),
ChangeNotifierProvider(create: (_) => AppLifecycleService()),
// ⭐ 生命周期管理器
ChangeNotifierProvider<AppLifecycleManager>(
create: (_) => lifecycleManager,
),
Provider<AudioPlayerHandler>(create: (_) => _audioHandler!),
Provider<AudioPlayerHandler>(
create: (_) => _audioHandler!,
),
],
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();
});
}
// ════════════════════════════════════════════════════════════
@@ -98,7 +133,13 @@ class RouteManager extends ChangeNotifier {
// ════════════════════════════════════════════════════════════
class MiniPlayerNavigatorObserver extends NavigatorObserver {
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);
}
@@ -109,12 +150,20 @@ class MiniPlayerNavigatorObserver extends NavigatorObserver {
@override
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
void didReplace({Route? newRoute, Route? oldRoute}) {
_updateRoute(newRoute);
if (newRoute != null && newRoute is! PopupRoute) {
final name = newRoute.settings.name ?? '/';
RouteManager().updateRoute(name);
}
}
}
@@ -129,71 +178,48 @@ class QTPlayerApp extends StatefulWidget {
}
class _QTPlayerAppState extends State<QTPlayerApp> {
// ⭐ build 计数器(诊断用)
static int _buildCount = 0;
bool _isInitializing = false;
// ⭐ 保存生命周期监听器引用
void Function(AppLifecycleStatus)? _lifecycleListener;
// ============================================================
// 生命周期
// ============================================================
@override
void initState() {
super.initState();
debugPrint('⏱️ T1.5 initState: ${stopwatch.elapsedMilliseconds}ms');
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('⏱️ T2 首帧回调: ${stopwatch.elapsedMilliseconds}ms');
// ⭐ 1. 初始化生命周期管理器
lifecycleManager.init();
// ⭐ 2. 绑定生命周期到播放器
_bindLifecycleToPlayback();
// ⭐ 3. 后台初始化
_startBackgroundInitialization();
});
}
@override
void dispose() {
// ⭐ 移除生命周期监听器
if (_lifecycleListener != null) {
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
}
super.dispose();
}
// ============================================================
// 生命周期 → 播放器绑定
// ============================================================
void _bindLifecycleToPlayback() {
if (_lifecycleListener != null) {
lifecycleManager.removeListenerFromManager(_lifecycleListener!);
}
_lifecycleListener = (status) {
final audioService = context.read<AudioService>();
switch (status) {
case AppLifecycleStatus.paused:
case AppLifecycleStatus.inactive:
debugPrint('🎵 App 进入后台,播放继续');
break;
case AppLifecycleStatus.resumed:
debugPrint('🎵 App 回到前台');
break;
case AppLifecycleStatus.backgroundIdle:
debugPrint('🎵 长后台唤醒,检查播放状态');
break;
default:
break;
}
@@ -202,9 +228,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
lifecycleManager.addListenerToManager(_lifecycleListener!);
}
// ============================================================
// 后台初始化(精细时间切片)
// ============================================================
Future<void> _startBackgroundInitialization() async {
if (_isInitializing) return;
_isInitializing = true;
@@ -212,7 +235,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
final tStart = DateTime.now();
debugPrint('⏱️ T3 后台初始化开始');
// ----- 1. PlaybackService -----
final t1 = DateTime.now();
try {
PlaybackService().init();
@@ -221,7 +243,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
debugPrint('❌ PlaybackService 失败: $e');
}
// ----- 2. WebDAV(只加载凭证) -----
final t2 = DateTime.now();
try {
final hasCred = await WebDAVService.instance.loadCredentials();
@@ -230,10 +251,8 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
debugPrint('❌ WebDAV 凭证加载失败: $e');
}
// ----- 3. ⭐ AudioService(后台播放核心) -----
await _initAudioService();
// ----- 4. 通知权限 -----
final t3 = DateTime.now();
try {
if (await Permission.notification.isDenied) {
@@ -250,9 +269,6 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
debugPrint('⏱️ T4 全部初始化完成: ${tEnd.difference(tStart).inMilliseconds}ms');
}
// ════════════════════════════════════════════════════════════
// ⭐ AudioService 初始化(后台播放核心)
// ════════════════════════════════════════════════════════════
Future<void> _initAudioService() async {
try {
await audio_service.AudioService.init(
@@ -270,43 +286,93 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
}
}
// ════════════════════════════════════════════════════════════
// 辅助方法
// ════════════════════════════════════════════════════════════
String _elapsedMs(DateTime start) {
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
Widget build(BuildContext context) {
_buildCount++;
debugPrint(
'🎨 [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(
// ⭐ 使用 AnnotatedRegion 包裹 MaterialApp,统一控制系统栏样式
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: const Color(0xFF1A1F1E),
systemNavigationBarIconBrightness: Brightness.light,
),
child: MaterialApp(
title: '清听',
theme: ThemeData(
brightness: Brightness.dark,
@@ -321,11 +387,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
),
navigatorKey: navigatorKey,
navigatorObservers: [MiniPlayerNavigatorObserver()],
routes: {
'/': (context) => const HomePage(),
'/player': (context) => const PlayerPage(),
'/playlist': (context) => const PlaylistPage(),
},
onGenerateRoute: _onGenerateRoute,
builder: (context, child) {
return Stack(
fit: StackFit.expand,
@@ -334,18 +396,42 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
AnimatedBuilder(
animation: RouteManager(),
builder: (context, _) {
if (!RouteManager().showMiniPlayer) {
return const SizedBox.shrink();
}
return const Align(
final routeManager = RouteManager();
final shouldShow = routeManager.currentRoute != '/player' &&
routeManager.currentRoute != '/playlist';
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,
child: GlobalMiniPlayer(),
)
: const SizedBox.shrink(
key: ValueKey('mini_player_hidden'),
),
);
},
),
],
);
},
),
);
}
}
+64
View File
@@ -0,0 +1,64 @@
// lib/metadata/file_provider.dart
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 {
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 {
@override
Future<File?> getFile(String url) async {
final file = File(url);
if (await file.exists()) {
return file;
}
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
// lib/metadata/metadata_cache.dart
import 'metadata_model.dart';
class MetadataCache {
final Map<String, FinalMetadata> _cache = {};
Future<FinalMetadata?> get(String fileId) async {
return _cache[fileId];
}
Future<void> put(String fileId, FinalMetadata metadata) async {
_cache[fileId] = metadata;
}
Future<void> update(String fileId, FinalMetadata metadata) async {
_cache[fileId] = metadata;
}
Future<void> remove(String fileId) async {
_cache.remove(fileId);
}
Future<List<Map<String, dynamic>>> getHistory(String artist,
{int limit = 10}) async {
return [];
}
Future<void> clear() async {
_cache.clear();
}
}
+16
View File
@@ -0,0 +1,16 @@
// lib/metadata/metadata_constants.dart
class MetadataConstants {
/// 首次 metadata 触发延迟(播放开始后等待时间)
static const Duration initialTriggerDelay = Duration(seconds: 20);
/// 重新确认进度点
static const double recheckProgress75 = 0.75;
static const double recheckProgress100 = 1.0;
/// 置信度阈值
static const double confidenceThreshold = 0.6;
/// 最小历史证据数量
static const int minHistoryCount = 4;
}
+143
View File
@@ -0,0 +1,143 @@
// lib/metadata/metadata_model.dart
import 'dart:typed_data';
/// 从音频文件读取的原始元数据
class RawMetadata {
final String title;
final String artist;
final String album;
final List<String> genres;
final List<String> performers;
final int year;
final int trackNumber;
final int trackTotal;
final int discNumber;
final int totalDisc;
final Duration? duration;
final Uint8List? artwork;
const RawMetadata({
this.title = '',
this.artist = '',
this.album = '',
this.genres = const [],
this.performers = const [],
this.year = 0,
this.trackNumber = 0,
this.trackTotal = 0,
this.discNumber = 0,
this.totalDisc = 0,
this.duration,
this.artwork,
});
bool get isEmpty =>
title.isEmpty &&
artist.isEmpty &&
album.isEmpty &&
genres.isEmpty &&
performers.isEmpty &&
year == 0;
bool get isNotEmpty => !isEmpty;
}
/// 标准化后的元数据
class NormalizedMetadata {
final String title;
final String artist;
final String album;
final List<String> genres;
final List<String> performers;
final int year;
final int trackNumber;
final int trackTotal;
final int discNumber;
final int totalDisc;
final Duration? duration;
final Uint8List? artwork;
const NormalizedMetadata({
this.title = '',
this.artist = '',
this.album = '',
this.genres = const [],
this.performers = const [],
this.year = 0,
this.trackNumber = 0,
this.trackTotal = 0,
this.discNumber = 0,
this.totalDisc = 0,
this.duration,
this.artwork,
});
bool get isEmpty =>
title.isEmpty &&
artist.isEmpty &&
album.isEmpty &&
genres.isEmpty &&
performers.isEmpty &&
year == 0;
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 {
final String title;
final String artist;
final String album;
final String genre;
final List<String> genres;
final List<String> performers;
final int year;
final int trackNumber;
final int trackTotal;
final int discNumber;
final int totalDisc;
final Duration? duration;
final Uint8List? artwork;
final String source;
const FinalMetadata({
this.title = '',
this.artist = '',
this.album = '',
this.genre = '',
this.genres = const [],
this.performers = const [],
this.year = 0,
this.trackNumber = 0,
this.trackTotal = 0,
this.discNumber = 0,
this.totalDisc = 0,
this.duration,
this.artwork,
this.source = 'unknown',
});
bool get isEmpty =>
title.isEmpty &&
artist.isEmpty &&
album.isEmpty &&
genre.isEmpty &&
genres.isEmpty &&
performers.isEmpty &&
year == 0;
bool get isNotEmpty => !isEmpty;
}
+82
View File
@@ -0,0 +1,82 @@
// lib/metadata/metadata_normalizer.dart
import 'metadata_model.dart';
class MetadataNormalizer {
NormalizedMetadata normalize(RawMetadata raw,
{String fileName = '', String filePath = ''}) {
final title = raw.title.trim();
final artist = raw.artist.trim();
final finalTitle =
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
final finalArtist =
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
final finalArtist2 = finalArtist.isNotEmpty
? finalArtist
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
return NormalizedMetadata(
title: finalTitle,
artist: finalArtist2,
album: raw.album,
genres: raw.genres,
performers: raw.performers,
year: raw.year,
trackNumber: raw.trackNumber,
trackTotal: raw.trackTotal,
discNumber: raw.discNumber,
totalDisc: raw.totalDisc,
duration: raw.duration,
artwork: raw.artwork,
);
}
String _inferTitleFromFileName(String fileName) {
final dotIndex = fileName.lastIndexOf('.');
return dotIndex > 0
? fileName.substring(0, dotIndex).trim()
: fileName.trim();
}
String _inferArtistFromPath(String filePath) {
final parts = filePath.split(RegExp(r'[/\\]'));
if (parts.length >= 2) {
final candidate = parts[parts.length - 2].trim();
if (candidate.isNotEmpty && !candidate.startsWith('http')) {
return candidate;
}
}
return '未知艺术家';
}
CandidateMetadata evaluate(
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
// 第一版:直接接受,置信度 0.8
return CandidateMetadata(
metadata: normalized,
confidence: 0.8,
evidence: {'normalized': 0.8},
);
}
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
return FinalMetadata(
title: candidate.metadata.title,
artist: candidate.metadata.artist,
album: candidate.metadata.album,
genre: candidate.metadata.genres.isNotEmpty
? candidate.metadata.genres.first
: '',
genres: candidate.metadata.genres,
performers: candidate.metadata.performers,
year: candidate.metadata.year,
trackNumber: candidate.metadata.trackNumber,
trackTotal: candidate.metadata.trackTotal,
discNumber: candidate.metadata.discNumber,
totalDisc: candidate.metadata.totalDisc,
duration: candidate.metadata.duration,
artwork: candidate.metadata.artwork,
source: 'normalized',
);
}
}
+59
View File
@@ -0,0 +1,59 @@
// lib/metadata/metadata_reader.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr;
import 'metadata_model.dart';
class MetadataReader {
Future<RawMetadata> readRawMetadata(File file) async {
try {
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(
title: meta.title ?? '',
artist: meta.artist ?? '',
album: meta.album ?? '',
genres: meta.genres,
performers: meta.performers,
year: _toInt(meta.year),
trackNumber: _toInt(meta.trackNumber),
trackTotal: _toInt(meta.trackTotal),
discNumber: _toInt(meta.discNumber),
totalDisc: _toInt(meta.totalDisc),
duration: meta.duration,
artwork: artwork,
);
} catch (e) {
debugPrint('⚠️ [MetadataReader] read failed: $e');
return const RawMetadata();
}
}
int _toInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
if (value is num) return value.toInt();
return 0;
}
}
+170
View File
@@ -0,0 +1,170 @@
// 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_reader.dart';
import 'metadata_normalizer.dart';
import 'metadata_cache.dart';
import 'file_provider.dart';
import '../utils/artwork_helper.dart';
class MetadataService {
static final MetadataService _instance = MetadataService._internal();
factory MetadataService() => _instance;
MetadataService._internal();
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');
}
Future<FinalMetadata> getMetadata({
required String url,
required String fileName,
required String fileId,
bool forceRefresh = false,
}) async {
// 1. 内存缓存
if (!forceRefresh) {
final cached = await _memoryCache.get(fileId);
if (cached != null && cached.isNotEmpty) {
debugPrint('📦 [MetadataService] memory hit: ${cached.title}');
return cached;
}
}
// 2. 获取文件
final provider = _getProvider(url);
final file = await provider.getFile(url);
if (file == null || !await file.exists()) {
debugPrint('⚠️ [MetadataService] file not available, using fallback');
return _fallbackFromFileName(fileName);
}
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) {
final dotIndex = fileName.lastIndexOf('.');
final title = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName;
return FinalMetadata(
title: title,
artist: '未知艺术家',
source: 'filename',
);
}
}
+69
View File
@@ -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
View File
@@ -6,6 +6,8 @@ import '../services/playback_service.dart';
import '../widgets/magnetic_scroll_physics.dart';
import 'webdav_setup_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();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends BaseState<HomePage> {
List<SongItem> _favorites = [];
bool _isLoading = true;
@@ -206,7 +208,7 @@ class _HomePageState extends State<HomePage> {
if (renderBox != null) {
final height = renderBox.size.height;
if (height > 0 && height != _mediaLibraryHeight) {
setState(() {
safeSetState(() {
_mediaLibraryHeight = height;
_heightMeasureRetryCount = 0;
});
@@ -219,7 +221,7 @@ class _HomePageState extends State<HomePage> {
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
} else {
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
setState(() {
safeSetState(() {
_mediaLibraryHeight = 280.0;
});
}
@@ -227,18 +229,18 @@ class _HomePageState extends State<HomePage> {
}
void _onScroll() {
if (mounted) setState(() {});
if (mounted) safeSetState(() {});
}
Future<void> _initWebDAV() async {
setState(() => _isLoading = true);
safeSetState(() => _isLoading = true);
try {
await WebDAVService.instance.loadCredentials();
_favorites = [];
} catch (e) {
_favorites = [];
} 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() {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
setState(() {});
safeSetState(() {});
}
@override
@@ -358,7 +407,7 @@ class _HomePageState extends State<HomePage> {
),
);
_resetSnapState();
setState(() {});
safeSetState(() {});
} else {
final result = await Navigator.push(
context,
@@ -366,17 +415,18 @@ class _HomePageState extends State<HomePage> {
builder: (_) => const WebDAVSetupPage(),
),
);
setState(() {});
safeSetState(() {});
if (result == true) {
await WebDAVService.instance
.loadCredentials();
setState(() {});
safeSetState(() {});
}
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧云朵图标(固定 56px
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(
@@ -386,11 +436,14 @@ class _HomePageState extends State<HomePage> {
),
),
const SizedBox(width: 16),
// 右侧所有内容
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 第一行:WebDAV + 状态 + 退出按钮
Row(
children: [
const Text(
@@ -412,9 +465,24 @@ class _HomePageState extends State<HomePage> {
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),
// 第二行:用户名
Text(
isConnected ? username : '点击连接',
style: TextStyle(
@@ -553,7 +621,7 @@ class _HomePageState extends State<HomePage> {
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 20,
bottom: UIConstants.miniPlayerBottomSpace,
),
sliver: _isLoading
? const SliverFillRemaining(
@@ -639,7 +707,7 @@ class _HomePageState extends State<HomePage> {
size: 20,
),
onPressed: () {
setState(() {
safeSetState(() {
_favorites.removeAt(index);
});
},
+98 -14
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../base/base_state.dart';
class PlayerPage extends StatefulWidget {
final VoidCallback? onClose;
@@ -17,7 +18,7 @@ class PlayerPage extends StatefulWidget {
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
class _PlayerPageState extends BaseState<PlayerPage> {
double? _dragProgress;
String _formatDuration(Duration d) {
@@ -43,6 +44,41 @@ class _PlayerPageState extends State<PlayerPage> {
}
}
@override
void initState() {
super.initState();
// ⭐ 页面加载时主动拉取状态(多次重试)
_syncStateWithRetry();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// ⭐ 依赖变化时(如从后台返回)重新同步
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.read<AudioService>().syncPlayerStateNow();
}
});
}
void _syncStateWithRetry() {
final service = context.read<AudioService>();
// 立即同步
service.syncPlayerStateNow();
// 延迟重试
const delays = [200, 500, 800, 1200];
for (final delay in delays) {
Future.delayed(Duration(milliseconds: delay), () {
if (mounted) {
service.syncPlayerStateNow();
}
});
}
}
@override
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
@@ -75,7 +111,6 @@ class _PlayerPageState extends State<PlayerPage> {
}
final isPlaying = service.isPlaying;
final duration = service.duration;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
@@ -96,9 +131,34 @@ class _PlayerPageState extends State<PlayerPage> {
),
centerTitle: true,
actions: [
IconButton(
PopupMenuButton<String>(
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('清除缓存'),
],
),
),
],
),
],
),
@@ -120,12 +180,17 @@ class _PlayerPageState extends State<PlayerPage> {
spreadRadius: 10,
),
],
image: song.artwork != null
? DecorationImage(
image: MemoryImage(song.artwork!),
fit: BoxFit.cover,
)
: null,
),
child: const Icon(
Icons.music_note,
size: 64,
color: Colors.white24,
),
child: song.artwork == null
? const Icon(Icons.music_note,
size: 64, color: Colors.white24)
: null,
),
const SizedBox(height: 48),
Column(
@@ -156,7 +221,7 @@ class _PlayerPageState extends State<PlayerPage> {
],
),
const SizedBox(height: 40),
_buildProgressSection(service, duration),
_buildProgressSection(service),
const SizedBox(height: 32),
_buildControlButtons(service, isPlaying),
const SizedBox(height: 16),
@@ -166,10 +231,24 @@ class _PlayerPageState extends State<PlayerPage> {
);
}
Widget _buildProgressSection(AudioService service, Duration duration) {
Widget _buildProgressSection(AudioService service) {
return ValueListenableBuilder(
valueListenable: service.positionNotifier,
builder: (context, position, _) {
return ValueListenableBuilder(
valueListenable: service.durationNotifier,
builder: (context, duration, _) {
// ⭐ 如果 duration 为 0,显示加载指示器
if (duration.inMilliseconds == 0) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(
color: Color(0xFFB8D4D0),
backgroundColor: Color(0xFF2A3332),
),
);
}
final progress = duration.inMilliseconds > 0
? position.inMilliseconds / duration.inMilliseconds
: 0.0;
@@ -189,8 +268,10 @@ class _PlayerPageState extends State<PlayerPage> {
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final bufferWidth = width * bufferProgress.clamp(0.0, 1.0);
final progressWidth = width * displayProgress.clamp(0.0, 1.0);
final bufferWidth =
width * bufferProgress.clamp(0.0, 1.0);
final progressWidth =
width * displayProgress.clamp(0.0, 1.0);
return Stack(
alignment: Alignment.centerLeft,
@@ -243,7 +324,8 @@ class _PlayerPageState extends State<PlayerPage> {
onChangeEnd: (value) {
final newPosition = Duration(
milliseconds:
(value * duration.inMilliseconds).round(),
(value * duration.inMilliseconds)
.round(),
);
service.seekTo(newPosition);
setState(() {
@@ -281,6 +363,8 @@ class _PlayerPageState extends State<PlayerPage> {
);
},
);
},
);
}
Widget _buildControlButtons(AudioService service, bool isPlaying) {
+199 -20
View File
@@ -1,10 +1,190 @@
// lib/pages/playlist_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
class PlaylistPage extends StatelessWidget {
class PlaylistPage extends StatefulWidget {
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
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
@@ -30,6 +210,10 @@ class PlaylistPage extends StatelessWidget {
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(service.playModeIcon, color: Colors.white54),
onPressed: service.togglePlayMode,
),
IconButton(
icon: const Icon(Icons.clear_all, color: Colors.white54),
onPressed: () {
@@ -37,29 +221,22 @@ class PlaylistPage extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text(
'清空播放列表',
style: TextStyle(color: Colors.white),
),
content: const Text(
'确定要清空当前播放列表吗?',
style: TextStyle(color: Colors.grey),
),
title: const Text('清空播放列表',
style: TextStyle(color: Colors.white)),
content: const Text('确定要清空当前播放列表吗?',
style: TextStyle(color: Colors.grey)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
child: const Text('取消')),
TextButton(
onPressed: () {
service.clearQueue();
Navigator.pop(context);
Navigator.pop(context);
},
child: const Text(
'清空',
style: TextStyle(color: Colors.red),
),
child:
const Text('清空', style: TextStyle(color: Colors.red)),
),
],
),
@@ -75,15 +252,15 @@ class PlaylistPage extends StatelessWidget {
children: [
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text(
'播放列表为空',
style: TextStyle(color: Colors.grey),
),
Text('播放列表为空', style: TextStyle(color: Colors.grey)),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// ⭐ 关键优化:固定 item 高度
itemExtent: _itemExtent,
itemCount: queue.length,
itemBuilder: (context, index) {
final song = queue[index];
@@ -121,12 +298,14 @@ class PlaylistPage extends StatelessWidget {
),
trailing: isCurrent
? Container(
key: _currentTagKey,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFB8D4D0).withOpacity(0.2),
color:
const Color(0xFFB8D4D0).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
+29 -100
View File
@@ -1,10 +1,9 @@
// lib/pages/webdav_file_list_page.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/webdav_service.dart';
import '../services/playback_service.dart';
import '../services/audio_service.dart';
import '../constants/ui_constants.dart';
import '../base/base_state.dart';
import '../controllers/playback_controller.dart';
class WebDAVFileListPage extends StatefulWidget {
final String currentPath;
@@ -15,7 +14,7 @@ class WebDAVFileListPage extends StatefulWidget {
State<WebDAVFileListPage> createState() => _WebDAVFileListPageState();
}
class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
List<WebDAVItem> _items = [];
bool _isLoading = true;
String _errorMessage = '';
@@ -41,7 +40,7 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
// 加载目录
// ============================================================
Future<void> _loadDirectory() async {
setState(() {
safeSetState(() {
_isLoading = true;
_errorMessage = '';
});
@@ -49,17 +48,21 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
try {
final items =
await WebDAVService.instance.listDirectory(path: _currentPath);
setState(() {
if (mounted) {
safeSetState(() {
_items = items;
_isLoading = false;
});
}
} catch (e) {
setState(() {
if (mounted) {
safeSetState(() {
_errorMessage = '加载失败: $e';
_isLoading = false;
});
}
}
}
// ============================================================
// 进入子目录
@@ -74,71 +77,20 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
}
// ============================================================
// ⭐ 核心:播放歌曲 + 自动构建队列
// ⭐ 播放单首(使用 PlaybackController
// ============================================================
void _playSong(WebDAVItem file) async {
try {
// 1. 获取当前目录所有音乐文件(过滤掉目录)
final musicFiles = _items.where((item) => !item.isDirectory).toList();
if (musicFiles.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('当前目录没有音乐文件'),
backgroundColor: Colors.orange,
),
await PlaybackController().playFromWebDAV(
directoryPath: _currentPath,
clickedSongPath: file.path,
allItems: _items,
);
}
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) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('正在播放: ${file.name}'),
content: Text('正在播放: ${_safeDecode(file.name)}'),
backgroundColor: const Color(0xFF4CAF50),
duration: const Duration(seconds: 1),
),
@@ -157,46 +109,20 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
}
// ============================================================
// 全部播放(从第一首开始
// 全部播放(使用 PlaybackController
// ============================================================
void _playAll() async {
try {
final musicFiles = _items.where((item) => !item.isDirectory).toList();
if (musicFiles.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('当前目录没有音乐文件'),
backgroundColor: Colors.orange,
),
await PlaybackController().playAllFromWebDAV(
directoryPath: _currentPath,
allItems: _items,
);
}
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) {
final musicCount = _items.where((item) => !item.isDirectory).length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('开始播放全部 (${queue.length}首)'),
content: Text('开始播放全部 ($musicCount 首)'),
backgroundColor: const Color(0xFF4CAF50),
duration: const Duration(seconds: 1),
),
@@ -248,7 +174,6 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
onPressed: () => Navigator.pop(context),
),
actions: [
// 全部播放按钮
IconButton(
icon: const Icon(Icons.playlist_play),
onPressed: _playAll,
@@ -314,8 +239,12 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: UIConstants.miniPlayerBottomSpace,
),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
+9 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../services/webdav_service.dart';
import 'webdav_file_list_page.dart';
import '../base/base_state.dart';
class WebDAVSetupPage extends StatefulWidget {
const WebDAVSetupPage({super.key});
@@ -9,7 +10,7 @@ class WebDAVSetupPage extends StatefulWidget {
State<WebDAVSetupPage> createState() => _WebDAVSetupPageState();
}
class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
class _WebDAVSetupPageState extends BaseState<WebDAVSetupPage> {
final _formKey = GlobalKey<FormState>();
final _baseUrlController = TextEditingController();
final _usernameController = TextEditingController();
@@ -34,7 +35,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
Future<void> _loadSavedCredentials() async {
final hasCred = await WebDAVService.instance.loadCredentials();
setState(() {
safeSetState(() {
_isConnected = hasCred;
_statusText = hasCred ? '已连接' : '未连接';
if (hasCred) {
@@ -46,7 +47,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
Future<void> _connect() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
safeSetState(() => _isLoading = true);
try {
await WebDAVService.instance.saveCredentials(
@@ -58,7 +59,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
// 验证连接
await WebDAVService.instance.getMusicFiles();
setState(() {
safeSetState(() {
_isConnected = true;
_statusText = '已连接';
_isLoading = false;
@@ -71,7 +72,8 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
backgroundColor: Color(0xFF4CAF50),
),
);
// ✅ 登录成功 → 直接跳转到文件列表页
// ⭐ 用 pushReplacement 替换当前页面(WebDAVSetupPage
// 此时栈底是 HomePage,替换后栈变为 [HomePage, WebDAVFileListPage]
Navigator.pushReplacement(
context,
MaterialPageRoute(
@@ -80,7 +82,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
);
}
} catch (e) {
setState(() {
safeSetState(() {
_isConnected = false;
_statusText = '连接失败';
_isLoading = false;
@@ -98,7 +100,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
Future<void> _disconnect() async {
await WebDAVService.instance.clearCredentials();
setState(() {
safeSetState(() {
_isConnected = false;
_statusText = '未连接';
});
+127
View File
@@ -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);
}
}
-3
View File
@@ -1,8 +1,5 @@
// lib/services/app_lifecycle_manager.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
/// App 生命周期状态(扩展了 Flutter 原生状态)
enum AppLifecycleStatus {
+165 -21
View File
@@ -1,84 +1,229 @@
// lib/services/audio_player_handler.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import '../audio/player_controller.dart';
import '../audio/playback_state_manager.dart';
import '../services/audio_service.dart'; // 你自己的业务 AudioService
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 {
final PlayerController _player = PlayerController();
final PlaybackStateManager _state = PlaybackStateManager();
String? _currentId;
String? _currentTitle;
String? _currentArtist;
Duration _currentPosition = Duration.zero;
DateTime _lastPublishTime = DateTime.now();
static const Duration _publishInterval = Duration(milliseconds: 500);
// ⭐ 缓存 artwork 文件路径,避免重复写入
String? _currentArtworkPath;
AudioPlayerHandler() {
// ---- 监听播放状态变化,同步到通知栏 ----
_player.playingStream.listen((playing) {
_state.updatePlaying(playing);
_publishState();
});
_player.positionStream.listen((position) {
_currentPosition = position;
_state.updatePosition(position);
final current = playbackState.value;
playbackState.add(audio_service.PlaybackState(
controls: current.controls,
processingState: current.processingState,
playing: current.playing,
androidCompactActionIndices: current.androidCompactActionIndices,
updateTime: DateTime.now(),
));
final now = DateTime.now();
if (now.difference(_lastPublishTime) >= _publishInterval) {
_lastPublishTime = now;
// ⭐ 只保留 _publishStateOnly()
_publishStateOnly();
}
});
_player.durationStream.listen((duration) {
debugPrint('🎯 [durationStream] duration=$duration');
_state.updateDuration(duration);
if (_currentId != null && _currentTitle != null) {
_updateMediaItem(
id: _currentId!,
title: _currentTitle!,
artist: _currentArtist!,
duration: duration,
);
}
_publishState();
});
// ⭐ 已删除 completed 监听,交由 AudioService 统一处理
// _player.completedStream.listen((_) { ... });
}
// ---- 发布状态 ----
void _publishState() {
playbackState.add(_state.playbackState);
final state = _state.playbackState;
playbackState.add(audio_service.PlaybackState(
controls: state.controls,
processingState: state.processingState,
playing: state.playing,
androidCompactActionIndices: state.androidCompactActionIndices,
updatePosition: _currentPosition,
updateTime: DateTime.now(),
systemActions: const {
audio_service.MediaAction.seek,
},
));
}
void updateNotification({
void _publishStateOnly() {
final current = playbackState.value;
playbackState.add(audio_service.PlaybackState(
controls: current.controls.isNotEmpty
? current.controls
: _state.playbackState.controls,
processingState: _state.playbackState.processingState,
playing: current.playing,
androidCompactActionIndices: current.androidCompactActionIndices,
updatePosition: _currentPosition,
updateTime: DateTime.now(),
systemActions: const {
audio_service.MediaAction.seek,
},
));
}
// ---- 更新媒体信息 ----
void _updateMediaItem({
required String id,
required String title,
required String artist,
Duration? duration,
Uint8List? artwork,
}) {
debugPrint('📢 [handler] updateNotification: $title');
debugPrint(
'📢 [handler] _updateMediaItem: artwork is ${artwork != null ? 'not null (${artwork.length} bytes)' : 'null'}');
_currentId = id;
_currentTitle = title;
_currentArtist = artist;
final position = _player.position;
debugPrint('📢 [handler] updateMediaItem: $title - $artist');
// ⭐ 异步处理 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(
id: id,
title: title,
artist: artist,
duration: _player.duration,
duration: duration ?? _player.duration,
extras: {'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({
required String id,
required String title,
required String artist,
Uint8List? artwork,
}) {
_updateMediaItem(
id: id,
title: title,
artist: artist,
artwork: artwork,
);
_publishState();
}
// ---- 控制命令 ----
@override
Future<void> play() async {
debugPrint('▶️ [handler] play() called');
_player.play();
_publishState();
}
@override
Future<void> pause() async {
debugPrint('⏸️ [handler] pause() called');
_player.pause();
_publishState();
}
@override
Future<void> stop() async {
debugPrint('⏹️ [handler] stop() called');
_player.stop();
_publishState();
}
@override
Future<void> seek(Duration position) async {
debugPrint('⏩ [handler] seek() called: $position');
_player.seek(position);
await _player.seek(position);
_currentPosition = position;
_publishStateOnly();
}
@override
@@ -113,7 +258,6 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
}
break;
default:
// 其他按钮 fallback
if (_player.isPlaying) {
await pause();
} else {
+605 -35
View File
@@ -1,8 +1,18 @@
// lib/services/audio_service.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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 '../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 {
sequential,
@@ -15,13 +25,22 @@ class Song {
final String title;
final String artist;
final String? url;
final Uint8List? artwork;
Song({
required this.id,
required this.title,
required this.artist,
this.url,
this.artwork,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'artist': artist,
'url': url,
};
}
class AudioService extends ChangeNotifier {
@@ -29,7 +48,7 @@ class AudioService extends ChangeNotifier {
factory AudioService() => _instance;
AudioService._internal();
// ---- 基础状态(低频,触发 UI 重建) ----
// ---- 基础状态 ----
Song? _currentSong;
bool _isPlaying = false;
PlayMode _playMode = PlayMode.sequential;
@@ -40,7 +59,10 @@ class AudioService extends ChangeNotifier {
List<int> _shuffledIndices = [];
int _shuffledIndex = -1;
// ---- ⭐ 高频进度(用 ValueNotifier,不触发全局重建) ----
// ---- 当前播放的歌单 ID ----
String? _currentPlaylistId;
// ---- 高频进度 ----
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> bufferedNotifier = ValueNotifier(Duration.zero);
@@ -48,18 +70,32 @@ class AudioService extends ChangeNotifier {
bool _listening = false;
final List<StreamSubscription> _subscriptions = [];
// ⭐ 防重入标志
// ---- 播放生命周期标志 ----
bool _handlingCompletion = false;
bool _isChangingTrack = false;
bool _hasStartedCurrentPlayback = false;
bool _isUserSeeking = false;
// ---- Getter(高频字段不走 ChangeNotifier ----
int _playbackGeneration = 0;
// ---- 待恢复的播放进度 ----
Duration? _pendingSeekPosition;
void Function(Song)? _onSongChanged;
// ---- Repository ----
final SongDatabase _db = SongDatabase();
final PlaylistRepository _playlistRepo = PlaylistRepository();
// ---- Getter ----
Song? get currentSong => _currentSong;
bool get isPlaying => _isPlaying;
PlayMode get playMode => _playMode;
List<Song> get queue => List.unmodifiable(_queue);
int get currentIndex => _currentIndex;
bool get hasQueue => _queue.isNotEmpty;
String? get currentPlaylistId => _currentPlaylistId;
// ---- 兼容旧代码:提供 getter 返回 ValueNotifier 的值 ----
Duration get position => positionNotifier.value;
Duration get duration => durationNotifier.value;
Duration get bufferedPosition => bufferedNotifier.value;
@@ -75,7 +111,10 @@ class AudioService extends ChangeNotifier {
}
}
// ---- 切换播放模式 ----
void setOnSongChanged(void Function(Song) callback) {
_onSongChanged = callback;
}
void togglePlayMode() {
switch (_playMode) {
case PlayMode.sequential:
@@ -89,10 +128,18 @@ class AudioService extends ChangeNotifier {
break;
}
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) {
_clearQueue();
return;
@@ -101,6 +148,7 @@ class AudioService extends ChangeNotifier {
_queue = List.from(queue);
_currentIndex = startIndex.clamp(0, _queue.length - 1);
if (_playMode == PlayMode.shuffle) {
_shuffledIndices = List.generate(_queue.length, (i) => i);
_shuffledIndices.shuffle();
_shuffledIndex = _shuffledIndices.indexOf(_currentIndex);
@@ -108,8 +156,12 @@ class AudioService extends ChangeNotifier {
_shuffledIndex = 0;
_currentIndex = _shuffledIndices[0];
}
} else {
_shuffledIndices = List.generate(_queue.length, (i) => i);
_shuffledIndex = _currentIndex;
}
_playCurrent();
await _playCurrent();
}
void _clearQueue() {
@@ -117,32 +169,101 @@ class AudioService extends ChangeNotifier {
_currentIndex = -1;
_shuffledIndices.clear();
_shuffledIndex = -1;
_currentPlaylistId = null;
stopPlay();
// 清空队列时也清除持久化状态
_db.savePlaybackState(
queueJson: [],
currentIndex: 0,
playMode: 'sequential',
positionMs: 0,
);
}
// ---- 播放指定歌曲 ----
Future<void> playSong(Song song) async {
_currentPlaylistId = null;
if (_queue.isEmpty || _queue[_currentIndex].id != song.id) {
setQueue([song], startIndex: 0);
await setQueue([song], startIndex: 0);
} 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) {
stopPlay();
return;
}
_isChangingTrack = true;
_handlingCompletion = false;
_hasStartedCurrentPlayback = false;
try {
_playbackGeneration++;
final generation = _playbackGeneration;
final song = _queue[_currentIndex];
_currentSong = song;
// ⭐ 重置进度(用 ValueNotifier
positionNotifier.value = Duration.zero;
durationNotifier.value = Duration.zero;
bufferedNotifier.value = Duration.zero;
_startListening();
notifyListeners();
@@ -150,31 +271,246 @@ class AudioService extends ChangeNotifier {
return;
}
PlaybackService().play(song.url!);
await _playWithHeaders(song.url!);
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');
}
// ---- 下一首 ----
void next() {
if (_queue.isEmpty) return;
_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;
final currentIndex = _currentIndex;
final song = _queue[currentIndex];
final songId = song.id;
if (song.url == null || song.url!.isEmpty) {
_onSongChanged?.call(song);
return;
}
try {
final metadata = await MetadataService().getMetadata(
url: song.url!,
fileName: song.title,
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) {
final updatedSong = Song(
id: song.id,
title: metadata.title.isNotEmpty ? metadata.title : song.title,
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
url: song.url,
artwork: metadata.artwork,
);
_queue[_currentIndex] = updatedSong;
_currentSong = updatedSong;
notifyListeners();
_onSongChanged?.call(updatedSong);
debugPrint(
'✅ [AudioService] metadata updated: ${updatedSong.title} - ${updatedSong.artist} (gen $generation)');
}
} catch (e) {
debugPrint('⚠️ [AudioService] metadata load failed: $e');
_onSongChanged?.call(song);
}
}
Future<void> clearCurrentSongCache() async {
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 (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
_shuffledIndex = nextIdx;
_currentIndex = _shuffledIndices[nextIdx];
_playCurrent();
await _playCurrent();
return;
}
final nextIdx = (_currentIndex + 1) % _queue.length;
_currentIndex = nextIdx;
_playCurrent();
await _playCurrent();
}
// ---- 上一首 ----
void previous() {
Future<void> previous() async {
if (_queue.isEmpty) return;
if (_isChangingTrack) {
debugPrint(
'⚠️ [AudioService] previous() ignored: track switch in progress');
return;
}
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
@@ -184,7 +520,7 @@ class AudioService extends ChangeNotifier {
_shuffledIndex = prevIdx;
}
_currentIndex = _shuffledIndices[_shuffledIndex];
_playCurrent();
await _playCurrent();
return;
}
@@ -194,7 +530,7 @@ class AudioService extends ChangeNotifier {
} else {
_currentIndex = prevIdx;
}
_playCurrent();
await _playCurrent();
}
// ---- 播放/暂停 ----
@@ -206,6 +542,8 @@ class AudioService extends ChangeNotifier {
} else {
PlaybackService().resume();
}
// 保存状态(包括进度)
savePlaybackState();
}
void stopPlay() {
@@ -216,11 +554,20 @@ class AudioService extends ChangeNotifier {
bufferedNotifier.value = Duration.zero;
_stopListening();
notifyListeners();
// 停止时也保存一次
savePlaybackState();
}
void seekTo(Duration position) {
_isUserSeeking = true;
PlaybackService().seek(position);
positionNotifier.value = position;
Future.delayed(const Duration(milliseconds: 800), () {
_isUserSeeking = false;
});
// 拖动后保存进度
savePlaybackState();
}
void clearQueue() {
@@ -228,10 +575,41 @@ class AudioService extends ChangeNotifier {
_currentIndex = -1;
_shuffledIndices.clear();
_shuffledIndex = -1;
_currentPlaylistId = null;
stopPlay();
notifyListeners();
}
void syncPlayerStateNow() {
final player = PlaybackService().player;
final pos = player.state.position;
final dur = player.state.duration;
final buf = player.state.buffer;
debugPrint(
'🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf');
if (pos.inMilliseconds >= 0) {
positionNotifier.value = pos;
}
if (dur.inMilliseconds > 0) {
durationNotifier.value = dur;
}
if (buf.inMilliseconds >= 0) {
bufferedNotifier.value = buf;
}
}
void _syncPlayerStateDelayed() {
syncPlayerStateNow();
Future.delayed(const Duration(milliseconds: 200), () {
syncPlayerStateNow();
});
Future.delayed(const Duration(milliseconds: 500), () {
syncPlayerStateNow();
});
}
// ---- 监听 media_kit 状态 ----
void _startListening() {
if (_listening) return;
@@ -244,19 +622,28 @@ class AudioService extends ChangeNotifier {
if (_isPlaying != playing) {
_isPlaying = playing;
notifyListeners();
// 播放状态变化时保存进度(暂停时已保存,但播放开始也可保存一次)
if (playing) savePlaybackState();
}
}),
);
_subscriptions.add(
player.stream.position.listen((position) {
if (_isUserSeeking) {
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
return;
}
positionNotifier.value = position;
}),
);
_subscriptions.add(
player.stream.duration.listen((duration) {
debugPrint('🎯 [AudioService] durationStream: $duration');
if (duration.inMilliseconds > 0) {
durationNotifier.value = duration;
}
}),
);
@@ -266,9 +653,45 @@ class AudioService extends ChangeNotifier {
}),
);
// ⭐ 唯一监听 completed 的地方
// ⭐ 完整生命周期校验的 completed 监听
_subscriptions.add(
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();
}),
);
@@ -282,27 +705,174 @@ class AudioService extends ChangeNotifier {
_subscriptions.clear();
}
// ⭐ 防重入
void _onPlaybackCompleted() {
if (_handlingCompletion) {
debugPrint('⚠️ [service] completed ignored: already handling');
debugPrint(
'🎵 [onPlaybackCompleted] ENTERED: index=$_currentIndex, total=${_queue.length}, mode=$_playMode');
if (_queue.isEmpty) {
debugPrint('⚠️ [onPlaybackCompleted] queue is empty, returning');
_handlingCompletion = false;
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 {
if (_queue.isEmpty) return;
if (_playMode == PlayMode.repeatOne) {
debugPrint(
'🎵 [onPlaybackCompleted] repeatOne mode, calling _playCurrent()');
_playCurrent();
return;
}
debugPrint('🎵 [onPlaybackCompleted] calling next()');
next();
} finally {
_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
void dispose() {
// 先保存再清理
savePlaybackState();
_stopListening();
positionNotifier.dispose();
durationNotifier.dispose();
+18 -7
View File
@@ -2,20 +2,31 @@ import 'package:media_kit/media_kit.dart';
class PlayerService {
static final PlayerService _instance = PlayerService._internal();
factory PlayerService() => _instance;
PlayerService._internal();
final Player _player = Player();
Player? _player;
void play(String url) {
_player.open(Media(url));
Player get player {
return _player ??= Player();
}
void pause() => _player.pause();
void resume() => _player.play();
void stop() => _player.stop();
void play(String url) {
player.open(Media(url));
}
void dispose() => _player.dispose();
void pause() => player.pause();
void resume() => player.play();
void stop() => player.stop();
void dispose() {
_player?.dispose();
_player = null;
}
// 状态监听...
}
+54
View File
@@ -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
}
}
}
+55
View File
@@ -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:// URIAndroid 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
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+35 -33
View File
@@ -2,20 +2,21 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../main.dart';
import '../utils/navigation.dart'; // ⭐ 导入 navigatorKey
class GlobalMiniPlayer extends StatelessWidget {
const GlobalMiniPlayer({super.key});
@override
Widget build(BuildContext context) {
// ⭐ 用 Selector 只监听 currentSong(低频变化)
final song = context.select<AudioService, Song?>((s) => s.currentSong);
// ⭐ 只监听播放状态(低频变化)
final isPlaying = context.select<AudioService, bool>((s) => s.isPlaying);
final bottomPadding = MediaQuery.of(context).padding.bottom;
if (song == null) {
return const SizedBox.shrink();
}
return Stack(
clipBehavior: Clip.none,
children: [
@@ -24,7 +25,7 @@ class GlobalMiniPlayer extends StatelessWidget {
right: 0,
bottom: 0,
child: Container(
height: 56 + bottomPadding,
height: 60 + bottomPadding,
color: const Color(0xFF1A1F1E),
),
),
@@ -37,36 +38,43 @@ class GlobalMiniPlayer extends StatelessWidget {
bottom: Radius.circular(16),
),
child: Container(
height: 56,
height: 60,
color: const Color(0xFF1A1F1E),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
if (song != null) {
// ⭐ 使用 navigatorKey 直接跳转
navigatorKey.currentState?.pushNamed('/player');
}
},
highlightColor: Colors.white.withOpacity(0.05),
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(
children: [
const SizedBox(width: 12),
Container(
width: 40,
height: 40,
width: 44,
height: 44,
decoration: BoxDecoration(
color: song != null
? const Color(0xFF2A3332)
: Colors.grey[800],
borderRadius: BorderRadius.circular(4),
color: const Color(0xFF2A3332),
image: song.artwork != null
? DecorationImage(
image: MemoryImage(song.artwork!),
fit: BoxFit.cover,
)
: null,
),
child: Icon(
song != null ? Icons.music_note : Icons.music_off,
color:
song != null ? Colors.white38 : Colors.grey[600],
child: song.artwork == null
? const Icon(
Icons.music_note,
color: Colors.white38,
size: 20,
),
)
: null,
),
const SizedBox(width: 12),
Expanded(
@@ -75,7 +83,7 @@ class GlobalMiniPlayer extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song != null ? song.title : '清听',
song.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
@@ -86,12 +94,10 @@ class GlobalMiniPlayer extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
Text(
song != null ? song.artist : '未播放',
song.artist,
style: TextStyle(
fontSize: 12,
color: song != null
? Colors.grey[400]
: Colors.grey[600],
color: Colors.grey[400],
decoration: TextDecoration.none,
),
maxLines: 1,
@@ -102,16 +108,12 @@ class GlobalMiniPlayer extends StatelessWidget {
),
IconButton(
icon: Icon(
song != null && isPlaying
? Icons.pause
: Icons.play_arrow,
color: song != null ? Colors.white : Colors.grey[600],
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 24,
),
onPressed: () {
if (song != null) {
context.read<AudioService>().togglePlay();
}
},
),
IconButton(
@@ -121,9 +123,8 @@ class GlobalMiniPlayer extends StatelessWidget {
size: 24,
),
onPressed: () {
if (song != null) {
// ⭐ 使用 navigatorKey 直接跳转
navigatorKey.currentState?.pushNamed('/playlist');
}
},
),
const SizedBox(width: 4),
@@ -134,6 +135,7 @@ class GlobalMiniPlayer extends StatelessWidget {
),
),
),
),
],
);
}
+45 -13
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
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:
dependency: transitive
description:
@@ -25,6 +33,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
audio_metadata_reader:
dependency: "direct main"
description:
name: audio_metadata_reader
sha256: "79b08282447dfc4b7ff955f9c4eff824d7284c7b021ccfe8204550d8801a08a5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.7.1"
audio_service:
dependency: "direct main"
description:
@@ -73,6 +89,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
charset:
dependency: transitive
description:
name: charset
sha256: "27802032a581e01ac565904ece8c8962564b1070690794f0072f6865958ce8b9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.1"
clock:
dependency: transitive
description:
@@ -98,7 +122,7 @@ packages:
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -224,6 +248,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.8.0"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.3"
jni:
dependency: transitive
description:
@@ -252,10 +284,10 @@ packages:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
version: "0.6.7"
leak_tracker:
dependency: transitive
description:
@@ -300,10 +332,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -372,10 +404,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.18.0"
version: "1.19.0"
mime:
dependency: transitive
description:
@@ -409,7 +441,7 @@ packages:
source: hosted
version: "3.0.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -417,7 +449,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
@@ -737,10 +769,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -777,10 +809,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
version: "2.4.2"
vm_service:
dependency: transitive
description:
+8 -3
View File
@@ -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
# 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.
version: 1.0.2+1
version: 1.1.0+1
environment:
sdk: ^3.0.0
@@ -30,17 +30,22 @@ environment:
dependencies:
flutter:
sdk: flutter
media_kit: ^1.1.10
media_kit: 1.2.6
media_kit_libs_audio: ^1.0.7
shared_preferences: 2.2.2
provider: ^6.1.2
dio: ^5.4.0 # HTTP 客户端
xml: ^6.5.0
audio_service: ^0.18.13
audio_service: ^0.18.19
audio_session: ^0.1.21
permission_handler: ^11.3.1
flutter_cache_manager: ^3.3.1
sqflite: ^2.3.0
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.
# Use with the CupertinoIcons class for iOS style icons.
+686
View File
@@ -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
[![Flutter](https://img.shields.io/badge/Flutter-3.x-blue)](https://flutter.dev)
[![Platform](https://img.shields.io/badge/Platform-Android-green)](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**:这是一个系统应该如何被长期运行。
>
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。