Files
QingTing-Player/readme-2.md
T

686 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
你说得完全对。上一版确实是把 `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**:这是一个系统应该如何被长期运行。
>
> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。
两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。