diff --git a/README.md b/README.md index 7a4897d..f6a7f03 100644 --- a/README.md +++ b/README.md @@ -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) —— 为什么选了这些方案 + +--- + +> 最好的播放器,是当你开始听歌之后,就再也感觉不到它技术细节的那一个。 \ No newline at end of file diff --git a/readme-2.md b/readme-2.md new file mode 100644 index 0000000..dd20daf --- /dev/null +++ b/readme-2.md @@ -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 _queue = []; + int _currentIndex = -1; + List _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**:这是一个系统应该如何被长期运行。 +> +> **清听**:这是一个播放器应该如何让用户忘记系统正在运行。 + +两个项目共享同一套工程哲学,但拥有各自独立的文档骨架。 \ No newline at end of file