Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .trellis/spec/app/playback/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 播放(`app/lib/playback/`)

改控制器、佇列、恢復策略或播放後端、寫播放測試、跑播放的實機驗證時適用。規則(唯一入口、
引擎套件只在後端目錄、共用規則、標頭、引擎訊息的遮蔽、一個後端實例、前瞻、恢復)與閘門見
`app/AGENTS.md` § 播放;為什麼這樣做,見 ADR 0018。這裡只寫怎麼做。

## 目錄

```
lib/playback/
playback_controller.dart # PlaybackController:唯一入口,唯一寫 PlaybackState
playback_state.dart # sealed PlaybackState、PlaybackProgress
queue_model.dart # QueueModel、QueueState(M1:記憶體、依序)
stream_resolver.dart # StreamResolver、ResolvedStream(期限)
recovery_policy.dart # decideRecovery 與它的輸入、輸出型別(純函數)
playback_providers.dart # audioBackendProvider、playbackControllerProvider、兩個狀態 stream
dev_playback_entry.dart # --fmp-dev-playback(只在 dev)
backends/
audio_backend.dart # AudioBackend 介面、BackendSource、狀態與事件
backend_rules.dart # classifyTrackEnd、LookAheadEdit(兩個後端共用)
audio_backends.dart # createAudioBackend:依平台宣告建實作
just_audio_backend.dart # Android
media_kit_backend.dart # Windows

lib/platform/audio/ # AudioBackendKind、PlayableFormat、PlaybackSupport 與兩個平台的宣告
```

## 一首歌怎麼播

1. `playQueue`/`next`/`previous`/恢復 → `_load`:換一代(`_generation`)、狀態 `Loading`,
用前瞻留下的 `ResolvedStream`(還沒過期)或呼叫 `StreamResolver.resolve`。
2. `_open`:建 `BackendSource`(新的 id、經 `mediaRequestHeaders`),`AudioBackend.open`。
3. 後端回報 `ready` → `Playing`/`Paused`;第一次 ready 時 `_prepareLookAhead` 解析下一首一次,
`setNext` 交給後端,有期限就排一個計時器在過期前 30 秒重新解析。
4. 引擎自己接上前瞻 → `SourceAdvanced`:佇列往下、換一代,接上的那首不再解析,再為下一首
準備前瞻。沒有前瞻時是 `SourceEnded`:還有下一首就照 1 開始,沒有就 `Idle`。
5. 失敗(`SourceFailed`、提前結束、解析丟出的 `AppError`)→ `decideRecovery` → 重試、換候選、
跳過或停下。

每個非同步步驟回來時比對代,不同就丟掉結果。狀態、位置、事件都帶來源 id,控制器只收
目前來源的。

## 改後端

- 引擎的型別只出現在兩個實作檔;介面的型別(`BackendStatus`、`BackendEvent`)以外的東西不要
往上傳。
- 兩個後端都要成立的規則寫進 `backend_rules.dart` 並在 `backend_rules_test.dart` 加案例,後端
只轉呼叫;只有一個引擎才有的怪癖寫在那個實作裡,收斂不了的差異寫進 `AudioBackend` 的
dartdoc。
- 清單的修改(`open`、`setNext`、`stop`、交接後的修剪)一律經 `_edit` 排隊,每次依當下的清單
算 `LookAheadEdit`;排隊期間來源換了就不做。
- `playing` 是使用者要不要出聲,不是引擎當下有沒有在輸出:mpv 在換檔的瞬間會把 `playing`、
`buffering` 閃一下,而且那時的來源還是舊的。
- mpv 的屬性只在值改變時發事件(兩個檔案一樣長時,接上後不會再收到 `duration`)。依賴屬性
事件的狀態,換來源時先從 `player.state` 取目前的值。
- media_kit 的事件不帶項目:`open` 之後、`Player.open` 回來之前收到的位置、時長、結束與錯誤
是上一個檔案的(`Player.open` 內的 mpv 指令是非同步的),`_opening` 期間一律丟掉。
- just_audio 在清單修改期間的事件一律丟掉,改完以 `_player.playbackEvent` 補一次:暫停中
載入好的來源之後不會再有事件,不補就一直停在緩衝。
- 要不要出聲以執行當下的意願(`_wantPlaying`)為準:`open` 排隊或載入中被 `pause` 的,
載入完不能照 `open` 當時的 `play` 開始播。契約的 `pausing before the source is ready…`
守這條。
- 引擎給的錯誤文字只放 `SourceFailed.cause` 或 log 的 `error:`,不接進訊息字串。
- 改完照 `app/AGENTS.md` § 驗證 在 Windows 與 Android 各跑一次真後端的契約。

## 測試

- 控制器:`test/playback/playback_controller_test.dart` 的 `Harness` 在 `fakeAsync` 裡組控制器、
`FakeAudioBackend`(計時器推進位置,所以假時間也會播完)與 `FakeSourcePlugin`(依請求回候選
或丟 `AppError`,記下每次請求)。`h.elapse` 前進時間,`h.settle` 只跑微任務。
- 解析次數用 `plugin.resolvedCount(sourceId)`;開了哪些網址用 `h.openedPaths`;前瞻用
`backend.nextSources`;log 用 `h.logged(message)`。
- 碰 log 檔的案例(遮蔽)不用 `fakeAsync`:真的 `LogFile` 在暫存目錄,等待用 `pumpUntil`。
- 後端契約:`audio_backend_contract.dart` 的 `audioBackendContract` 收一個 `DefineCase`,
`flutter test` 傳 `test`,整合測試傳包了 `testWidgets`+`runAsync` 的版本。新的後端行為在這裡
加一個案例,假後端與真後端一起跑到;`FakeAudioBackend` 用同一份 `backend_rules.dart`。
- 真後端要實際播放,契約的 `Recorder.until` 等的是實際時間(事件驅動+逾時計時器),不是
`pumpUntil`。

## 實機驗證(ADR 0018 §如何確認)

測試插件的三首都是同一個 2 秒的 `tone.wav`(dev flavor 的 asset),不連網。

- Windows:`flutter build windows --flavor dev --debug`,再
`build\windows\x64\dev\runner\Debug\fmp.exe --fmp-dev-playback`。dev 是單一實例,先關掉其他
開著的 FMP Dev(包括別的 worktree 的)。log 在同目錄的 `userdata-dev\logs\fmp.jsonl`。
- Android 模擬器:`flutter build apk --flavor dev --debug`、`adb install -r
build\app\outputs\flutter-apk\app-dev-debug.apk`,再 `adb shell am start -n
com.personal.fmp.dev/com.personal.fmp.MainActivity --esal dart_entrypoint_args
--fmp-dev-playback`。debug build 的 log 在 `adb logcat -s flutter`。模擬器要有聲音輸出
(不是 `-no-audio` 啟動的),ExoPlayer 才會真的播。
- 看交接:`Look-ahead handover`(`previousPositionMs` 是上一首最後回報的位置)與接著的
`Track audible`:`sinceHandoverMs` 是交接事件到這首第一次回報位置,`estimatedGapMs` 是從
上一首最後的位置推算的結束時間到這首第一次回報位置(含位置回報的間隔,只是估計)。
身分頁的 `Dev playback:` 一行是狀態與第幾首。
- Android 音訊焦點:播放中與交接前後重複 `adb shell dumpsys audio`,看 `Audio Focus stack`
的最上面一直是 `com.personal.fmp.dev`(`AUDIOFOCUS_GAIN`),沒有被 abandon 又重新 request;
播完之後也仍在(just_audio 不主動放)。
- 真實連線(可選、ADR 0027 §決定 2 的最少操作):`--fmp-dev-plugin=<B 站插件>
--fmp-dev-playback=bilibili:<BV 號>` 播一首,看 `Opening stream` 的 `headers` 有
`Referer`。多首就重複 `--fmp-dev-playback=`;Android 的 `--esal` 以逗號分隔陣列,寫成
`--esal dart_entrypoint_args --fmp-dev-plugin=<路徑>,--fmp-dev-playback=bilibili:<BV 號>`。
16 changes: 13 additions & 3 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

## 進度與交接(2026-09-30 更新;compact 後從這裡接)

- **已合併進 `main`**:#173(設計文件)、#174(PR 1 指令檔分家)、#175(PR 2 骨架與 CI)、#177(PR 3 lint)、#178(PR 4 平台層)、#179(PR 5 drift)、#180(PR 6 log 與設定)、#181(PR 7 錯誤模型)、#182(PR 8 網路層)、#183(PR 9a JS 執行環境)、#184(PR 9b 契約執行器)。#176 是 CI 路徑探測,已關閉。
- **已合併進 `main`**:#173(設計文件)、#174(PR 1 指令檔分家)、#175(PR 2 骨架與 CI)、#177(PR 3 lint)、#178(PR 4 平台層)、#179(PR 5 drift)、#180(PR 6 log 與設定)、#181(PR 7 錯誤模型)、#182(PR 8 網路層)、#183(PR 9a JS 執行環境)、#184(PR 9b 契約執行器)、#185(PR 9c 紀錄)、#186(遮蔽修正)。#176 是 CI 路徑探測,已關閉。
- **isar/sqlite3 共存探針**:已完成,兩平台共存、全部 16KB 對齊(`research/isar-sqlite3-coexistence.md`,ADR 0010 已補)。
- **PR 9a 完成**(#183,子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-js-runtime/`):每插件一個背景 isolate 的 QuickJS、宿主 API v1、manifest、從檔案安裝與 dev 開發入口、測試插件 `fmp-test`;數字在該子任務 `research/notes.md` §4。
- 實機:Windows dev 開發入口裝上、重啟後從資料庫載入、prod 不理會旗標;Android 模擬器 dev 的前兩項。模擬器上的 `com.personal.fmp` 是舊版 1.11.0,prod 沒裝上去驗;prod 那一段由單元測試守(`devPluginPath` 對 prod 一律回 `null`,有變異驗證)。
Expand All @@ -35,7 +35,10 @@
- 在 dev App 裝它:`fmp.exe --fmp-dev-plugin=<fmp-plugins>/bilibili/bilibili.js`(Android 照 9a 的 `run-as` 做法)。
- 真實連線:兩次錄製共 8 個 GET,沒有遇到風控;fixture 人工逐檔檢查過。
- **遮蔽修正**:`hdnts`/`buvid` 進內建名單、同 host 的規則合併套用;fmp-plugins 的 B 站 fixture 同步重新遮蔽。
- **下一步**:YouTube.js 探針(與 10–13 並行)→ 10 播放核心 → 11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。
- **進行中(2026-09-30 起並行)**:
- PR 10 播放核心:分支 `feat/playback-core`,子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-playback-core/`。實機(審查前的產物):Windows 測試插件三首連播、交接估計間隔 49 ms;Android 估計 -43/18 ms,整段只有一次 `requestAudioFocus`、沒有 `abandonAudioFocus`;Windows 以 B 站插件真實播放一首(3 個 API 請求、只帶 Referer/User-Agent,log 無 CDN 網址)。審查修了 5 個缺陷(含 Android 載入中暫停會永遠卡在 Loading);修正後 Android 真後端契約連續 5 次 10/10、Windows 10/10,兩平台連播與焦點重跑通過(Windows 50/49 ms,Android -206/30 ms、焦點只要求一次)。
- YouTube.js 探針:在 Agent 的 worktree、分支 `probe/youtubejs`(不合併、不 push);結論寫在該 worktree 的 `research/youtubejs-probe.md`,回報後由主對話開子任務收進 `research/`,並在 ADR 0014 §決定 10 補一句結論。
- **之後**:11 verify-on-device → 12 UI → 13 五平台建置與發版 workflow → 里程碑驗收。
- **擁有者決定**:1–8 都在父任務 `prd.md`「擁有者的決定」。9a、9b 期間新增了三項:
- 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest;
- 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟;
Expand All @@ -49,7 +52,7 @@
6. 使用者看得到的改動,由主對話實機驗證(Windows 用 msaa_tree.ps1 讀畫面文字;Android 用 adb 的 `run-as` 與 `screencap`);
7. 派 opus `trellis-check`,要它試著攻破安全相關的部分;
8. 把後續待辦寫進本檔;
9. `git checkout --` 還原只有換行差異的產生檔(`generated_plugin*`、`app_database.g.dart`、`GeneratedPluginRegistrant.swift`);
9. `git checkout --` 還原只有換行差異的產生檔(`generated_plugin*`、`app_database.g.dart`、`GeneratedPluginRegistrant.swift`)——**逐檔**用 `git diff --ignore-all-space --ignore-cr-at-eol` 確認是空的才還原;加了原生插件的 PR 有真正新增的註冊,整批還原會把它們弄丟(PR 10 踩過,`flutter pub get` 可重新產生);
10. 分開 commit;
11. `task.py finish`,再 `archive <slug> --no-commit --skip-branch-validation`,把 archive commit 掉;
12. push,`gh pr create`(繁中描述+review 指南);
Expand Down Expand Up @@ -201,6 +204,13 @@
- [ ] 登入後 `_AuthInterceptor` 以 `headers.addAll` 注入 `Cookie`,會整個蓋掉插件送的匿名 `buvid3`;舊專案是合併。M3 登入任務決定合併或交給插件。
- [ ] B 站插件的 `rateLimit`(併發 2、間隔 300ms)沒有量測依據;`allowedHosts` 外的 PCDN(`szbdyd.com`、直接寫 IP 的節點)會被丟掉,舊專案不限制。M6 媒體 client 接上時一併看。
- [ ] PR 8 的 `ignoreInvalidCookies` 觀察:兩次錄製的回應都沒有 `Set-Cookie`,沒觀察到;留到會發 cookie 的端點(登入)。

PR 10 留下的後續:

- [ ] `app/android/app/src/main/AndroidManifest.xml` 沒有 `INTERNET` 權限(只有 `debug/`、`profile/` 有),release 建置連不了網路;PR 13 處理。
- [ ] 開不起來的串流在換過候選後對應 `Unsupported`,ADR 0013 會顯示成「視為 bug」的通用訊息;CDN 403 落到這裡不貼切,PR 12 做提示時再看。
- [ ] 前瞻開不起來時兩個後端的行為沒有契約案例(Android 會被當成目前這首中斷;Windows 可能卡在 Playing);前瞻解析比目前這首播完還慢時會多解析一次。M2 補契約案例。
- [ ] 被取代的 `resolveStream` 只丟結果、不取消網路工作(`SourcePlugin` 沒有取消參數)。
- [ ] 媒體 CDN 的簽名參數目前是整個拿掉;內建名單每變嚴格一次,既有 fixture 就過不了「再遮蔽一次不變」的掃描(遮蔽修正 PR 時手動改了 fmp-plugins 的 24 個網址)。審查建議改成「值換成 `***`」:已遮過的不再誤紅、明文照樣紅。改動是 `_redactMediaUrl` 一行加既有測試期望,M3 插件庫 CI 上線前做。
- [ ] 插件每重新載入一次,`Redactor._mediaCdns` 就多一份相同規則(輸出不受影響,只是多掃);M3 插件頁的重新載入出現時一併去重。

Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"09-30-js-runtime",
"09-30-plugin-contract",
"09-30-bilibili-plugin",
"09-30-redaction-cdn-rules"
"09-30-redaction-cdn-rules",
"09-30-playback-core"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{"file": "docs/adr/0018-playback-core.md", "reason": "Playback core decisions and verification"}
{"file": "docs/adr/0013-unified-error-model.md", "reason": "Error classes driving recovery"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Platform capabilities, playable formats"}
{"file": ".trellis/spec/app/platform/index.md", "reason": "Platform layer and capability declarations"}
{"file": ".trellis/spec/app/plugins/index.md", "reason": "SourcePlugin, resolveStream DTOs"}
{"file": ".trellis/spec/app/network/index.md", "reason": "mediaRequestHeaders policy"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "fmp_layer_imports dependency table"}
{"file": ".trellis/spec/app/testing/index.md", "reason": "Test conventions"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{"file": "docs/adr/0018-playback-core.md", "reason": "Playback core decisions and verification"}
{"file": "docs/adr/0013-unified-error-model.md", "reason": "Error classes driving recovery"}
{"file": "docs/adr/0009-platform-layer-with-declared-capabilities.md", "reason": "Platform capabilities, playable formats"}
{"file": ".trellis/spec/app/platform/index.md", "reason": "Platform layer and capability declarations"}
{"file": ".trellis/spec/app/plugins/index.md", "reason": "SourcePlugin, resolveStream DTOs"}
{"file": ".trellis/spec/app/network/index.md", "reason": "mediaRequestHeaders policy"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "fmp_layer_imports dependency table"}
{"file": ".trellis/spec/app/testing/index.md", "reason": "Test conventions"}
69 changes: 69 additions & 0 deletions .trellis/tasks/archive/2026-09/09-30-playback-core/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 播放核心最小集(M1 PR 10)

父任務:`../09-28-m1-skeleton-tracer`(implement「10.」;design 3.16 與「媒體 client」一列)。前一批:9a–9c 與遮蔽修正(#183–#186)。

依據:
- ADR 0018:決定 1–3、6 的 M1 部分,以及「如何確認」;
- ADR 0013:錯誤類別;
- ADR 0012:交給播放後端的 headers 經 `mediaRequestHeaders`;
- ADR 0009:平台層宣告後端與可播格式;
- ADR 0015:`fmp_layer_imports`,`just_audio`、`media_kit` 只在後端實作目錄。

## 做什麼

1. **`AudioBackend` 介面與兩個實作**:
- `JustAudioBackend` 給 Android,`MediaKitBackend` 給 Windows;
- 平台層以能力宣告選用哪一個,並宣告**可播格式**。Linux、macOS、iOS 照 ADR 0009 §決定 4 宣告「沒有播放能力」(原寫「只宣告」,與 ADR 衝突;以後用哪個後端寫在 `AudioBackendKind` 的 dartdoc)。
- 不能收斂的差異寫進介面的 dartdoc。
- 可收斂的規則寫成共用純函數,並有契約測試:
- 結束原因分類;
- 前瞻計畫。
- 後端只持有「目前+一個前瞻」。
- 套件版本取 pub.dev 最新 stable,先查官方文件:
- `just_audio` 怎麼做 gapless 與前瞻(`ConcatenatingAudioSource` 或新版的替代做法)、headers;
- `media_kit` 的 playlist 或 prefetch、headers,以及 Windows 需要的 libs 套件。
2. **`PlaybackController`**:UI 唯一的播放入口,M1 只有這些操作:
- 播放某一首(清單加起點);
- 播放、暫停;
- 上一首、下一首;
- seek。

狀態:
- 一份 sealed 播放狀態:`Idle`、`Loading`、`Playing`、`Paused`、`Buffering`、`Retrying`、`Failed(AppError)`;
- 位置、時長、緩衝走獨立的 stream;
- 協作者只回報、不寫狀態。
3. **`QueueModel`**:只在記憶體,只有 `queue` 模式,依序播放,不持久化(design 3.16)。
- 介面照 ADR 0018,只做 M1 需要的部分,不寫空殼;
- `QueueState` 與播放狀態沒有共同欄位。
4. **`StreamResolver`**:直接呼叫插件的 `resolveStream`(M1 沒有下載與網址快取)。
- 輸入:`TrackKey`、平台可播格式、用途 `playback`。
- 候選依優先序;開流失敗換候選一次。
- 前瞻交接前檢查 `expiresAt`,過期就重新解析。
- 交給後端的 headers 一律先經 `mediaRequestHeaders`。
5. **錯誤恢復的 M1 部分**(`RecoveryPolicy`,純函數):
- `Unavailable`、`NotFound`、需登入、`Unsupported`:跳過;
- `NetworkError`、`RateLimited`:重試 1、3、9 秒共 3 次,再跳過;
- 連續跳過達佇列長度就停止。

M1 沒有 `Online` 偵測與提示 UI:失敗以狀態表達,提示在 PR 12 接 `Toaster`。
6. **Android 換歌時不釋放音訊焦點**(ADR 0018 §決定 3、§7 實測)。
7. **dev 的驗證入口**:
- 用測試插件 `fmp-test` 的本機音檔,連續播兩首並記下交接;可選 B 站插件(見下)。
- 形式可以是 `integration_test`、只在 dev 的入口,或只在 dev 顯示的身分頁按鈕,二擇一並寫明。
- 不進正式路徑;PR 12 的 UI 上線後刪掉或保留,寫明。

## 驗收

- [ ] `app/` 驗證清單全過:format、build_runner 沒有實質變動、`dart analyze --fatal-infos`、`flutter analyze`、`flutter test`、哨兵。
- [ ] 單元測試:
- `QueueModel`:依序、上一首或下一首的邊界;
- `RecoveryPolicy`:每一類錯誤;
- 前瞻只解析一次;
- 過期重新解析;
- 換候選一次。
- [ ] 後端契約測試:同一份純規則斷言,跑兩個實作與假後端。真後端跑不了 `flutter test` 的部分,寫明改在 `integration_test` 或實機。
- [ ] 實機(主對話執行,§7):
- Windows 與 Android 模擬器用測試插件連續播兩首,第二首由前瞻接上;記錄交接的間隔。
- Android 換歌時音訊焦點沒有被釋放(`dumpsys audio` 的焦點堆疊)。
- 可選:用 B 站插件真實播放一首,確認 Referer 的 headers 有生效,屬 ADR 0027 允許的最少操作。
- [ ] `fmp_layer_imports` 擋得住後端套件出現在實作目錄以外,並有雙向變異驗證。
Loading
Loading