Files

17 KiB
Raw Permalink Blame History

你说得完全对。上一版确实是把 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 —— 入口

# 清听 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 →

📖 文档


最好的播放器,是当你开始听歌之后,就再也感觉不到它技术细节的那一个。


---

#### 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 —— 播放链路(核心文档)

# 播放链路

> *“一首歌从点击到喇叭出声,中间发生了什么?”*

这是清听最核心的文档。它描述的是整个播放系统如何协同工作,让用户获得连续、可靠的聆听体验。

---

## 总览

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 机制(防漂移)

这是清听处理异步竞态的关键设计。

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 发布

playbackState.add(PlaybackState(
  controls: [skipToPrevious, playPause, skipToNext],
  playing: playing,
  position: _currentPosition,
  updateTime: DateTime.now(),
  systemActions: {MediaAction.seek},
));
  • systemActions 必须包含 seek,通知栏进度条才能拖动
  • 使用 playPause 而非分离的 play/pause
  • 每 500ms 节流发布,平衡性能与流畅度

完整时序

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 —— 设计决策

# 设计决策

> *“为什么最终选了这些方案?”*

每个决策三段:**问题 → 决策 → 原因**

---

## 为什么用 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:这是一个系统应该如何被长期运行。

清听:这是一个播放器应该如何让用户忘记系统正在运行。

两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。