diff --git a/.trellis/spec/app/playback/index.md b/.trellis/spec/app/playback/index.md new file mode 100644 index 00000000..4fd0aa6b --- /dev/null +++ b/.trellis/spec/app/playback/index.md @@ -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= + --fmp-dev-playback=bilibili:` 播一首,看 `Opening stream` 的 `headers` 有 + `Referer`。多首就重複 `--fmp-dev-playback=`;Android 的 `--esal` 以逗號分隔陣列,寫成 + `--esal dart_entrypoint_args --fmp-dev-plugin=<路徑>,--fmp-dev-playback=bilibili:`。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index e3af076d..20f7d853 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -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`,有變異驗證)。 @@ -35,7 +35,10 @@ - 在 dev App 裝它:`fmp.exe --fmp-dev-plugin=/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 執行;逾時先送存活探測,沒回應才停用到重啟; @@ -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 --no-commit --skip-branch-validation`,把 archive commit 掉; 12. push,`gh pr create`(繁中描述+review 指南); @@ -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 插件頁的重新載入出現時一併去重。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index 65fe5cdd..e3ff759d 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -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": [], diff --git a/.trellis/tasks/archive/2026-09/09-30-playback-core/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-playback-core/check.jsonl new file mode 100644 index 00000000..6432e6b3 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-playback-core/check.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-30-playback-core/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-playback-core/implement.jsonl new file mode 100644 index 00000000..6432e6b3 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-playback-core/implement.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-30-playback-core/prd.md b/.trellis/tasks/archive/2026-09/09-30-playback-core/prd.md new file mode 100644 index 00000000..817d0b29 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-playback-core/prd.md @@ -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` 擋得住後端套件出現在實作目錄以外,並有雙向變異驗證。 diff --git a/.trellis/tasks/archive/2026-09/09-30-playback-core/research/notes.md b/.trellis/tasks/archive/2026-09/09-30-playback-core/research/notes.md new file mode 100644 index 00000000..024e167b --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-playback-core/research/notes.md @@ -0,0 +1,111 @@ +# 播放核心(M1 PR 10)研究筆記 + +2026-09-30。context7 在這個 session 不可用,以 pub.dev API、pub-cache 裡的原始碼 +(行號對應下列版本)與上游 repo 查證。 + +## 1. 套件版本(pub.dev 最新 stable) + +| 套件 | 版本 | 發佈 | 用途 | +|---|---|---|---| +| `just_audio` | 0.10.6 | 2026-06-29 | Android 後端(ExoPlayer) | +| `audio_session` | 0.2.4 | 2026-06-29 | just_audio 的傳遞依賴,不直接用 | +| `media_kit` | 1.2.6 | 2025-12-13 | Windows 後端(libmpv),純 Dart+FFI,沒有 Android 原生碼 | +| `media_kit_libs_windows_audio` | 1.0.9 | 2023-09-27 | Windows 的 libmpv(`mpv-dev-x86_64-20230924-git-652a1dd.7z`,建置時從 GitHub 下載並驗 MD5,見套件的 `windows/CMakeLists.txt`) | +| `fake_async` | 1.3.3 | — | dev:控制器測試的假時間(`flutter_test` 已依賴它,但不 export) | + +- media_kit README 建議音訊 App 用 `media_kit_libs_audio`;它的依賴是 + `media_kit_libs_android_audio`、`_ios_audio`、`_macos_audio`、`_windows_audio`、 + `media_kit_libs_linux`(pub.dev API),會把 Android 的 libmpv 打進 APK。ADR 0018 + 否決「全平台 media_kit」的理由之一就是這個體積,所以只加 Windows 那一個。 + +## 2. just_audio(Android) + +- **前瞻與 gapless**:0.10.0 起 `ConcatenatingAudioSource` 棄用,改用 player 上的 + playlist API:`setAudioSources`、`addAudioSource`、`removeAudioSourceAt` + (CHANGELOG 0.10.0;`just_audio.dart:877-947`)。README 的功能表 Android 有 + gapless。`useLazyPreparation` 從 audio source 搬到 `AudioPlayer` 建構子;設 + `false` 讓 ExoPlayer 一接上第二個項目就預備(`just_audio.dart:225-250`)。 +- **標頭**:預設經 just_audio 的本機 HTTP proxy 送,需要允許明文流量(README)。 + `useProxyForRequestHeaders: false` 時交給 ExoPlayer:每個 media source 各自的 + `DefaultHttpDataSource.Factory.setDefaultRequestProperties(headers)` + (`android/.../AudioPlayer.java:638-651, 731-747`)。採用後者,不必開明文流量。 +- **asset**:`asset:///` 由 just_audio 先複製到暫存檔再交給引擎 + (`just_audio.dart:2746-2757`);載不到的 asset 在交給 ExoPlayer 前就拋,不是 + `PlayerException`,所以後端的 `open` 以 `on Object` 接。 +- `play()` 的 Future 要到暫停才完成(README 與原始碼),不能 await。 +- `playing` 跨 `setAudioSources` 保留:要停在暫停就先 `pause()`。 +- 錯誤:0.10 以 `errorStream` 取代 `playbackEventStream.onError`,`PlayerException` + 帶項目的 `index`(CHANGELOG 0.10.0;`just_audio.dart:1810-1836`)。 + +### Android 音訊焦點(ADR 0018 §決定 3 的「換歌時不釋放」) + +- ExoPlayer 自己不管焦點:`player.setAudioAttributes(attrs, false)` + (`AudioPlayer.java:355, 387, 818`,第二個參數是 `handleAudioFocus`)。 +- just_audio 在每次 `play()` 呼叫 `AudioSession.setActive(true)` + (`just_audio.dart:1098`);整個套件沒有 `setActive(false)`。 +- audio_session 的 Android 端:`requestAudioFocus` 已有請求就直接回 true,不重新 + 要求(`AndroidAudioManager.kt:345-347`);只在收到 `AUDIOFOCUS_LOSS`(353)或 + 引擎卸載時的 `dispose`(725)放掉。 +- 結論:同一個 `AudioPlayer` 換來源、`stop()`(只卸掉 ExoPlayer)都不放焦點; + 所以整個 App 只建一個 `AudioPlayer`(`audioBackendProvider`),不在換歌時重建。 + 實測以 `dumpsys audio` 的焦點堆疊確認(主對話執行)。 +- 不另外 import `audio_session` 記焦點事件:焦點只在別的 App 搶走時才變,實機以 + `dumpsys audio` 看比 log 準;需要時(M2 的中斷處理)再加依賴與 owner。 + +## 3. media_kit(Windows) + +- `Player.open(Playlist([...]))`、`add(Media)`、`remove(index)`,都經內部 lock 序列化; + `remove` 自己調整目前的索引並以同一份 `Media` 物件清單發 `playlist` 事件 + (`native/player/real.dart:137-235, 455-560`)。所以以 `Expando` 從 `Media` 實例 + 對回來源 id,不用網址(不同來源可以同網址,例如測試插件的三首都是同一個音檔)。 +- **標頭**:`Media(uri, httpHeaders:)` 存進以網址為鍵的全域快取,在 mpv 的 + `on_load` hook 設成 per-file 的 `http-header-fields`(`real.dart:2124-2175`、 + `media_native.dart:104-120`)。同一個網址只有一組標頭。 +- **前瞻**:mpv 的 `prefetch-playlist` 預設 `no`,media_kit 不設;後端設成 `yes`。 + mpv 手冊說它 "Highly experimental"、用 per-file 選項時可能不準;舊專案 + (`lib/services/audio/media_kit_audio_service.dart:241-250`)以只認正確 Referer 的 + 本機伺服器實測過前瞻開流帶的標頭是對的。 +- **結束與交接**:media_kit 用 `keep-open=yes`,`completed` 來自 `eof-reached`; + 目前項目來自 `playlist-playing-pos`(`real.dart:1604-1618, 1943-1960`)。有下一個 + 項目時 mpv 自己接上,`completed` 可能在換檔的瞬間閃一下(Namida 的 + `custom_mpv_player.dart` 有同樣的註解),所以有前瞻時忽略它。 +- **錯誤**:只有 log 行(`file`、`ffmpeg` 的 `tcp:`、`ad`、`vd`、`cplayer`、`stream` + 前綴的 error 級),不帶項目(`real.dart:2060-2117`)。 +- **屬性事件只在值改變時發**(本 PR 的 Windows 整合測試發現):兩個一樣長的檔案, + 接上前瞻後不會再收到 `duration`。後端交接時先沿用 `player.state.duration`。 +- `MediaKit.ensureInitialized()` 可以延到第一次建立 `Player` 前才呼叫(只載入 + libmpv),不必在 `main()`。 +- `PlayerConfiguration.title` 是 Windows 音量混合器顯示的名稱,預設 + `package:media_kit`,改成 `FMP`。 + +## 4. 參考實作與採用的慣例(ADR 0018 列的三個) + +- **Namida**(`namidaco/namida@5cb84bfa52`,`lib/class/custom_mpv_player.dart`): + 一個播放器介面(`AVPlayer`),ExoPlayer 與 mpv 兩個實作;前瞻用 + `addMediaNext`(只保留「目前+一個」:加在目前之後,再移掉尾巴與已播的頭)與 + `removeAllMediaNext`;gapless 交接以「清單索引移到排隊的那個 `Media`(identical)」 + 判斷並發 `autoTransition` 事件。**採用**:`AudioBackend` 的 `open`/`setNext`、 + 後端以物件身分判斷交接並發 `SourceAdvanced`。 +- **Harmonoid**(`harmonoid/harmonoid@2b021f7b0b`,`lib/core/media_player/`): + `MediaPlayer` 單例直接包 media_kit 的 `Player`,播放清單在 media_kit 裡,系統媒體 + 控制、audio_session、Discord 等以 mixin 註冊、由狀態推送。M1 沒有系統媒體控制; + 它的「推送去重與序列化」留給 M2 的 `NowPlayingPublisher`(ADR 0018 §決定 8)。 + 佇列真相在原生清單這點是 ADR 0018 否決的,不採用。 +- **Finamp**(`jmshrv/finamp`,`lib/services/music_player_background_task.dart`): + `BaseAudioHandler`(audio_service)包一個 just_audio `AudioPlayer`,整個佇列放進 + `ConcatenatingAudioSource`。同樣是原生清單當真相,不採用;audio_service 的接法 + 留給 M2。 +- **舊專案**(`lib/services/audio/`):`playback_end_reason_rules.dart` 的 + `classifyCompletion`(時長沒回報=提前結束、容忍 1.5 秒)與 + `next_media_plan.dart` 的 `NextMediaPlan` 照搬成 `backend_rules.dart` 的 + `classifyTrackEnd`、`LookAheadEdit`(ADR 0008:葉節點照搬)。mpv/ExoPlayer 錯誤 + 訊息的分類表(輸出裝置、傳輸、開不起來、解碼)M1 不搬:M1 只分「還沒載入就失敗 + (換候選)」與「播放中中斷(重試)」,輸出裝置失敗的處理在 ADR 0018 §決定 7, + 屬 M2。 + +## 5. 實測(本 PR 內做的) + +- Windows(media_kit 真後端):`flutter test integration_test/audio_backend_contract_test.dart -d windows` + 7/7 通過(第一次跑抓到 §3 的 duration 問題,修正後通過)。 +- Android(just_audio 真後端):同一個檔案 `-d emulator-5554`,由主對話執行(本 + session 的模擬器同時被別的 worktree 使用,跑整合測試會覆蓋它安裝的 dev App)。 diff --git a/.trellis/tasks/archive/2026-09/09-30-playback-core/task.json b/.trellis/tasks/archive/2026-09/09-30-playback-core/task.json new file mode 100644 index 00000000..21c1defc --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-playback-core/task.json @@ -0,0 +1,26 @@ +{ + "id": "playback-core", + "name": "playback-core", + "title": "播放核心最小集", + "description": "M1 PR 10: PlaybackController, sealed state, JustAudio/MediaKit backends, two-track in-memory queue, look-ahead handover", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-30", + "completedAt": "2026-09-30", + "branch": "feat/playback-core", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/app/AGENTS.md b/app/AGENTS.md index 8f664d49..755b326c 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -13,6 +13,7 @@ | `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | | 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | | drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | +| 播放後端(`lib/playback/backends/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/audio_backend_contract_test.dart -d <裝置>`(見 § 播放) | - `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 與產生檔檢查(見「資料層」); @@ -233,7 +234,7 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 `NoCredentials`(每個音源都未登入)。閘門:`auth_test.dart`(三種標記 × 三種狀態)。 - 媒體 client 延到 M6。交給播放後端的串流 headers 一律先經 `mediaRequestHeaders`:只留 `Referer`、`User-Agent`、`Origin`、`Range`。閘門:`media_headers_test.dart`;後端確實 - 經過它,由 PR 10 的測試接手。 + 經過它,見「播放」。 ## 插件 @@ -303,6 +304,56 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 執行器看不到的:插件接住並吞掉「網域不在清單」的錯誤(網路層照樣不送出);插件自己拋的 `ParseError` 與 DTO 驗證失敗的 `ParseError` 分不出來。沒有閘門,已知限制。 +## 播放 + +`lib/playback/`(ADR 0018)。怎麼改後端、寫播放測試、跑實機驗證: +`.trellis/spec/app/playback/index.md`。M1 只有依序播放一個清單、播放與暫停、上一首與 +下一首、seek,佇列只在記憶體。 + +- `PlaybackController` 是 UI 唯一的播放入口,也是 `PlaybackState` 唯一的寫入者; + `QueueModel`、`StreamResolver`、`decideRecovery`(純函數)與後端只回報。沒有閘門, + review 時看。 +- `just_audio`、`media_kit`(含 `media_kit_libs_*`)只准在 `lib/playback/backends/` + import。閘門:lint `fmp_layer_imports`(`layer_imports_test.dart` 的 + `test_playbackEnginesOutsideTheBackends`:同前綴的 `lib/playback/backends_helpers.dart` + 也報;`test_playbackEnginesInTheBackends`:後端目錄與 `media_kitchen` 這類相似套件名 + 不報)。ADR 0018 另外兩條(結束原因型別只給後端與路由器、串流存取的窄介面只給 + `PlaybackSession`)等 M2 有路由器與 `PlaybackSession` 時再加;M1 的路由在控制器裡。 +- 兩個後端共用的規則(結束分類 `classifyTrackEnd`、前瞻的清單修改 `LookAheadEdit`)只在 + `backends/backend_rules.dart`,後端只轉呼叫。閘門:`backend_rules_test.dart`;後端契約 + `test/playback/backends/audio_backend_contract.dart` 以同一份斷言跑假後端(`flutter + test`)與平台的真後端(`integration_test/audio_backend_contract_test.dart`:Windows + 是 media_kit、Android 是 just_audio)。**真後端的那一份 CI 不跑**,改後端時照上面的 + 驗證表在兩個平台手動跑。 +- 交給後端的串流只能是 `BackendSource`,它在建構時就經過 `mediaRequestHeaders`,所以 + 沒有別的路徑把 `Cookie` 之類交給播放引擎。閘門:`backend_rules_test.dart` 的 + `BackendSource`、`playback_controller_test.dart` 的 `the backend only gets media headers`。 +- 引擎的錯誤(mpv 的 log 行、ExoPlayer 的例外)可能帶完整的簽名網址:後端只以 + `SourceFailed.cause` 交出或以 `error:` 交給門面,不放進訊息、不自己印。閘門: + `playback_controller_test.dart` 的 `engine messages in the log`(假的 googlevideo 簽名 + 網址經 mpv 行與例外兩種形狀,記憶體歷史與 log 檔都沒有原值)。media_kit 後端自己的 + 那一行 warning 走同一個 `error:` 參數,但後端在 `flutter test` 裡建不起來,沒有直接的 + 閘門。 +- 整個 App 只有一個後端實例(`audioBackendProvider`):just_audio 在 `play()` 經 + audio_session 要求 Android 音訊焦點,只在失去焦點或引擎卸載時放掉,換來源、`stop()` + 都不放;重建 `AudioPlayer` 才會(ADR 0018 §決定 3;來源在 `AudioBackend` 的 + dartdoc)。沒有自動閘門,實機以 `dumpsys audio` 確認(見 spec)。 +- 前瞻:目前這首載入好後解析下一首一次,交接時不再解析;候選的 `expiresAt` 前 30 秒 + (`ResolvedStream.expiryMargin`)重新解析並換掉前瞻,手動下一首也先檢查。閘門: + `playback_controller_test.dart` 的 `hands over to the look-ahead…`、`pausing and + resuming…`、`expiry` 群組。 +- 恢復(ADR 0018 §決定 7 的 M1 部分):網路錯誤、限流、中斷與提前結束從目前位置重試 + 1/3/9 秒;開不起來換下一個候選一次;其他錯誤類別跳過;連續跳過達佇列長度(最多 10) + 停在 `Failed`。M1 沒有連線偵測、試聽片段設定(一律跳過)、緩衝飢餓與輸出裝置的處理、 + 「正常播放 10 秒後重試計數歸零」(M1 換歌才歸零),也沒有提示 UI(PR 12)。閘門:`recovery_policy_test.dart`、`playback_controller_test.dart` + 的 `recovery` 群組。 +- 被取代的解析結果丟掉,但插件的 `resolveStream` 沒有取消參數,網路工作不取消 + (ADR 0018 §決定 6 的取消等插件 API 支援)。已知限制。 +- 播放的開發入口:dev flavor 帶 `--fmp-dev-playback` 啟動就安裝內附的測試插件並依序播 + 它的三首;`--fmp-dev-playback=<曲目鍵>`(可重複;不用逗號,Android 的 `--esal` 以逗號切 + 陣列)播指定的曲目(插件同時以 `--fmp-dev-plugin` 安裝)。prod 不讀,理由同插件的開發入口。PR 12 的播放列能走同一條路後刪掉。閘門: + `dev_playback_entry_test.dart` 的 `prod reads nothing`。 + ## 設定 `lib/settings/`(ADR 0011 §決定 7)。怎麼加一個設定欄位:`.trellis/spec/app/settings/index.md`。 diff --git a/app/integration_test/audio_backend_contract_test.dart b/app/integration_test/audio_backend_contract_test.dart new file mode 100644 index 00000000..d2f74d0c --- /dev/null +++ b/app/integration_test/audio_backend_contract_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/platform/platform.dart'; +import 'package:fmp/playback/backends/audio_backends.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../test/playback/backends/audio_backend_contract.dart'; + +// 後端契約的真後端那一份(ADR 0018 §如何確認):跑執行平台宣告的後端—— +// Android 是 just_audio、Windows 是 media_kit。音檔是測試插件的 tone.wav(2 秒, +// dev flavor 的 asset),所以要帶 `--flavor dev`(預設就是 dev): +// +// flutter test integration_test/audio_backend_contract_test.dart -d windows +// flutter test integration_test/audio_backend_contract_test.dart -d emulator-5554 +// +// 會出聲。假後端的那一份在 test/playback/backends/,由 `flutter test` 跑。 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final support = AppPlatform.current(AppFlavor.dev).capabilities.playback; + if (support == null) { + test('this platform declares no playback backend', () {}, skip: true); + return; + } + final log = Log(redactor: Redactor(), minimumLevel: LogLevel.debug); + + group(support.backend.name, () { + audioBackendContract( + define: (description, body) => testWidgets( + description, + (tester) => tester.runAsync(body), + timeout: const Timeout(Duration(minutes: 1)), + ), + create: () async => createAudioBackend(support.backend, log: log), + track: Uri.parse('asset:///test/fixtures/plugins/test_plugin/tone.wav'), + missing: Uri.parse( + 'asset:///test/fixtures/plugins/test_plugin/missing.wav', + ), + trackLength: const Duration(seconds: 2), + ); + }); + + tearDownAll(() { + for (final record in log.history) { + if (record.level.index >= LogLevel.warning.index) { + debugPrint('FMP_BACKEND_LOG ${record.message} ${record.fields}'); + } + } + }); +} diff --git a/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart index efd27438..01e827f3 100644 --- a/app/lib/app/fmp_app.dart +++ b/app/lib/app/fmp_app.dart @@ -4,12 +4,15 @@ import 'package:material_ui/material_ui.dart'; import 'package:fmp/core/app_flavor.dart'; import 'package:fmp/core/errors/app_error.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; +import 'package:fmp/playback/dev_playback_entry.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; import 'package:fmp/plugins/install/dev_plugin_entry.dart'; import 'package:fmp/plugins/plugin_registry.dart'; import 'package:fmp/plugins/source_plugin.dart'; /// App 根元件。目前只顯示 App 名稱、flavor、資料目錄與載入的插件,供實機確認 -/// 身分與插件的開發入口;正式的外殼在 M1 PR 12。 +/// 身分與開發入口(插件、播放);正式的外殼在 M1 PR 12。 class FmpApp extends StatelessWidget { const FmpApp({super.key, required this.flavor}); @@ -43,6 +46,7 @@ class _IdentityPage extends ConsumerWidget { Text(flavor.name), SelectableText(ref.watch(dataDirectoryProvider).path), const _PluginList(), + const _DevPlayback(), ], ), ), @@ -78,3 +82,39 @@ class _PluginList extends ConsumerWidget { ); } } + +/// 播放的開發入口(只在帶了 `--fmp-dev-playback` 時):目前的播放狀態與第幾首, +/// 或開始失敗的錯誤類別。 +class _DevPlayback extends ConsumerWidget { + const _DevPlayback(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(devPlaybackRequestProvider) == null) { + return const SizedBox.shrink(); + } + final start = ref.watch(devPlaybackProvider); + if (start case AsyncError(:final error)) { + return Text( + 'Dev playback: ${error is AppError ? error.typeName : 'failed'}', + ); + } + final state = switch (ref.watch(playbackStateProvider).value) { + null => '-', + Idle() => 'idle', + Loading() => 'loading', + Playing() => 'playing', + Paused() => 'paused', + Buffering() => 'buffering', + Retrying(:final error, :final attempt) => + 'retrying ${error.typeName} #$attempt', + Failed(:final error) => 'failed ${error.typeName}', + }; + final queue = ref.watch(playbackQueueProvider).value; + final index = queue?.currentIndex; + return Text( + 'Dev playback: $state' + '${index == null ? '' : ' ${index + 1}/${queue!.tracks.length}'}', + ); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index 9c6926a3..05a6e24e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -20,6 +20,8 @@ import 'package:fmp/data/database/open_app_database.dart'; import 'package:fmp/data/providers.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; import 'package:fmp/platform/platform.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; +import 'package:fmp/playback/dev_playback_entry.dart'; import 'package:fmp/plugins/install/dev_plugin_entry.dart'; Future main(List arguments) async { @@ -80,11 +82,16 @@ Future main(List arguments) async { dataDirectoryProvider.overrideWithValue(directory), logProvider.overrideWithValue(log), redactorProvider.overrideWithValue(redactor), + platformCapabilitiesProvider.overrideWithValue(platform.capabilities), // 插件的開發入口只在 dev(devPluginPath 在 prod 回 null;理由見 // dev_plugin_entry.dart)。 devPluginPathProvider.overrideWithValue( devPluginPath(flavor, arguments, Platform.environment), ), + // 播放的開發入口也只在 dev(dev_playback_entry.dart)。 + devPlaybackRequestProvider.overrideWithValue( + devPlaybackRequest(flavor, arguments), + ), ], child: FmpApp(flavor: flavor), ), diff --git a/app/lib/platform/audio/audio.dart b/app/lib/platform/audio/audio.dart new file mode 100644 index 00000000..f321db56 --- /dev/null +++ b/app/lib/platform/audio/audio.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +/// 播放後端的兩個實作(ADR 0018 §決定 3)。平台層只宣告用哪一個,實作在 +/// `lib/playback/backends/`(`just_audio`、`media_kit` 只准在那裡 import)。 +enum AudioBackendKind { + /// `just_audio`:Android(之後 iOS、macOS)。 + justAudio, + + /// `media_kit`(libmpv):Windows(之後 Linux)。 + mediaKit, +} + +/// 平台能播的一種格式(ADR 0009 §決定 2、ADR 0018 §決定 6)。值是小寫的慣用 +/// 名稱,與插件 DTO 的 `StreamFormat` 同一套(容器 `mp4`、`webm`,編碼 +/// `aac`、`opus`);播放模組把它轉成 `StreamFormat` 交給插件。 +@immutable +final class PlayableFormat { + const PlayableFormat(this.container, this.codec); + + final String container; + final String codec; + + @override + bool operator ==(Object other) => + other is PlayableFormat && + other.container == container && + other.codec == codec; + + @override + int get hashCode => Object.hash(container, codec); + + @override + String toString() => '$container/$codec'; +} + +/// 平台的播放能力:用哪個後端、能播哪些格式。 +@immutable +final class PlaybackSupport { + const PlaybackSupport({required this.backend, required this.formats}); + + final AudioBackendKind backend; + + /// 能播的格式,依偏好排序;插件依它挑候選串流。 + final List formats; +} diff --git a/app/lib/platform/audio/audio_android.dart b/app/lib/platform/audio/audio_android.dart new file mode 100644 index 00000000..28bbac00 --- /dev/null +++ b/app/lib/platform/audio/audio_android.dart @@ -0,0 +1,17 @@ +import 'package:fmp/platform/audio/audio.dart'; + +/// Android:`just_audio`(ExoPlayer)。 +/// +/// 格式只列 M1 的音源會給的:B 站的 DASH 音訊(fMP4/AAC)與 FLAC、YouTube +/// 的 AAC 與 Opus、網易的 MP3 與 FLAC,以及測試插件的 WAV。ExoPlayer 的支援表: +/// https://developer.android.com/media/media3/exoplayer/supported-formats +const androidPlaybackSupport = PlaybackSupport( + backend: AudioBackendKind.justAudio, + formats: [ + PlayableFormat('mp4', 'aac'), + PlayableFormat('webm', 'opus'), + PlayableFormat('mp3', 'mp3'), + PlayableFormat('flac', 'flac'), + PlayableFormat('wav', 'pcm_s16le'), + ], +); diff --git a/app/lib/platform/audio/audio_windows.dart b/app/lib/platform/audio/audio_windows.dart new file mode 100644 index 00000000..aa7eda03 --- /dev/null +++ b/app/lib/platform/audio/audio_windows.dart @@ -0,0 +1,16 @@ +import 'package:fmp/platform/audio/audio.dart'; + +/// Windows:`media_kit`(libmpv,內建 FFmpeg 解碼)。 +/// +/// 格式與 Android 相同:清單表達的是 M1 的音源會給的格式,不是 FFmpeg 能解的 +/// 全部。直播的 FLV/HLS 在直播的里程碑加。 +const windowsPlaybackSupport = PlaybackSupport( + backend: AudioBackendKind.mediaKit, + formats: [ + PlayableFormat('mp4', 'aac'), + PlayableFormat('webm', 'opus'), + PlayableFormat('mp3', 'mp3'), + PlayableFormat('flac', 'flac'), + PlayableFormat('wav', 'pcm_s16le'), + ], +); diff --git a/app/lib/platform/platform.dart b/app/lib/platform/platform.dart index 9d5012e2..55100f72 100644 --- a/app/lib/platform/platform.dart +++ b/app/lib/platform/platform.dart @@ -7,6 +7,8 @@ import 'package:fmp/core/app_flavor.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory_android.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory_windows.dart'; +import 'package:fmp/platform/audio/audio_android.dart'; +import 'package:fmp/platform/audio/audio_windows.dart'; import 'package:fmp/platform/fonts/fonts_android.dart'; import 'package:fmp/platform/fonts/fonts_windows.dart'; import 'package:fmp/platform/platform_capabilities.dart'; @@ -33,6 +35,7 @@ final class AppPlatform { dataDirectory: true, singleInstance: false, fontFallback: androidFontFallback, + playback: androidPlaybackSupport, ), dataDirectory: AndroidAppDataDirectory( flavor: flavor, @@ -45,6 +48,7 @@ final class AppPlatform { dataDirectory: true, singleInstance: true, fontFallback: windowsFontFallback, + playback: windowsPlaybackSupport, ), dataDirectory: WindowsAppDataDirectory( flavor: flavor, diff --git a/app/lib/platform/platform_capabilities.dart b/app/lib/platform/platform_capabilities.dart index a93bc1f6..991304fb 100644 --- a/app/lib/platform/platform_capabilities.dart +++ b/app/lib/platform/platform_capabilities.dart @@ -1,7 +1,17 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fmp/platform/audio/audio.dart'; import 'package:fmp/platform/fonts/fonts.dart'; +/// 目前平台的能力宣告。`main()` 以 `AppPlatform` 組出的宣告 override;沒 +/// override 就讀會拋錯。 +final platformCapabilitiesProvider = Provider( + (ref) => throw UnimplementedError( + 'platformCapabilitiesProvider is overridden by main()', + ), +); + /// 目前平台有哪些能力(ADR 0009 §決定 2)。每個平台一份,由 /// `platform.dart` 組出;UI 依它決定是否顯示入口。 /// @@ -13,6 +23,7 @@ final class PlatformCapabilities { required this.dataDirectory, required this.singleInstance, required this.fontFallback, + required this.playback, }); /// 還沒驗證的平台:什麼都沒有。 @@ -20,6 +31,7 @@ final class PlatformCapabilities { dataDirectory: false, singleInstance: false, fontFallback: FontFallback.none, + playback: null, ); /// 有 App 資料目錄的實作(`app_data_directory/`)。沒有時 `main()` 不啟動 @@ -33,4 +45,7 @@ final class PlatformCapabilities { /// 各介面語言的 CJK 字型 fallback(ADR 0024 §決定 2)。 final FontFallback fontFallback; + + /// 播放用的後端與可播格式(ADR 0018 §決定 3);沒有播放的實作時為 `null`。 + final PlaybackSupport? playback; } diff --git a/app/lib/playback/backends/audio_backend.dart b/app/lib/playback/backends/audio_backend.dart new file mode 100644 index 00000000..058fa88e --- /dev/null +++ b/app/lib/playback/backends/audio_backend.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/core/network/media_headers.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; +import 'package:fmp/playback/playback_state.dart'; + +/// 播放引擎的介面(ADR 0018 §決定 3):`JustAudioBackend`(Android)與 +/// `MediaKitBackend`(Windows)兩個實作,平台層宣告用哪一個 +/// (`PlaybackSupport.backend`)。只由 `PlaybackController` 呼叫。 +/// +/// **只持有「目前+一個前瞻」**:[open] 換掉整個清單,[setNext] 設定或清掉 +/// 前瞻;前瞻接上後,後端自己把播完的那一個移掉([LookAheadEdit])。後端不 +/// 知道佇列,也不解析網址。 +/// +/// 狀態、位置與事件都帶 [BackendSource.id]:broadcast stream 是非同步送達的, +/// 換來源之後還可能收到上一個來源的值,控制器以 id 過濾。 +/// +/// 收斂不了的差異: +/// +/// - **Android 的音訊焦點**:just_audio 在每次 `play()` 經 audio_session 要求 +/// 焦點,只在失去焦點(`AUDIOFOCUS_LOSS`)或引擎卸載時放掉,換來源、 +/// `stop()` 都不放;所以整個 App 只用一個 `AudioPlayer`,不在換歌時重建 +/// (ADR 0018 §決定 3)。Windows 沒有音訊焦點。 +/// - **前瞻怎麼預備**:just_audio 以 `useLazyPreparation: false` 讓 ExoPlayer +/// 一接上就預備第二個項目;mpv 靠 `prefetch-playlist=yes`。交接都由引擎 +/// 自己做(gapless),這裡只收到 [SourceAdvanced]。 +/// - **錯誤是誰的**:ExoPlayer 的錯誤帶項目索引;mpv 只給一行 log,不帶項目, +/// 所以 [MediaKitBackend] 把錯誤算在目前的來源上,前瞻預備時的錯誤也一樣 +/// (只影響「還沒載入」的判斷)。 +/// - **標頭**:just_audio 以 `useProxyForRequestHeaders: false` 直接交給 +/// ExoPlayer(不開本機 proxy,不需要明文流量);media_kit 在 mpv 的 +/// `on_load` hook 設 `http-header-fields`,以網址為鍵,同一個網址只有一組 +/// 標頭。 +abstract interface class AudioBackend { + /// 狀態的變化。 + Stream get status; + + /// 目前來源的位置;播放中持續發出,seek 後也發出。 + Stream get progress; + + /// 來源的交接、結束與失敗。 + Stream get events; + + /// 以 [source] 取代整個清單(目前與前瞻),從 [start] 開始;[play] 為假時 + /// 載入後停在暫停。開不起來不丟出,發 [SourceFailed]([BackendFailure.open])。 + Future open( + BackendSource source, { + Duration start = Duration.zero, + bool play = true, + }); + + /// 設定目前來源之後的前瞻;`null` 清掉。沒有目前的來源時什麼都不做。 + Future setNext(BackendSource? next); + + Future play(); + + Future pause(); + + Future seek(Duration position); + + /// 停止並清空清單。不放掉 Android 的音訊焦點(見上)。 + Future stop(); + + Future dispose(); +} + +/// 交給後端的一個串流。 +/// +/// [headers] 在建構時就經過 `mediaRequestHeaders`(ADR 0012):只留媒體請求 +/// 的 header,所以交給播放引擎的東西不可能帶 `Cookie` 之類的憑證。 +@immutable +final class BackendSource { + BackendSource({ + required this.id, + required this.url, + Map headers = const {}, + }) : headers = Map.unmodifiable(mediaRequestHeaders(headers)); + + /// 控制器給的代號,事件以它指回這個來源。 + final int id; + + /// `https` 網址,或 App 內附的 `asset:///…`。 + final Uri url; + final Map headers; +} + +/// 後端的狀態。 +@immutable +final class BackendStatus { + const BackendStatus({ + required this.sourceId, + required this.playing, + required this.phase, + }); + + /// 狀態屬於哪個來源;清單是空的時為 `null`。 + final int? sourceId; + + /// 要不要出聲(暫停時為假)。播到清單結尾時為假。 + final bool playing; + final BackendPhase phase; + + @override + bool operator ==(Object other) => + other is BackendStatus && + other.sourceId == sourceId && + other.playing == playing && + other.phase == phase; + + @override + int get hashCode => Object.hash(sourceId, playing, phase); + + @override + String toString() => + 'BackendStatus(sourceId: $sourceId, playing: $playing, ' + 'phase: ${phase.name})'; +} + +enum BackendPhase { + /// 沒有來源。 + idle, + + /// 開流中,或中途等資料。 + buffering, + + /// 載入好了,可以出聲。 + ready, + + /// 播到清單結尾。 + ended, +} + +/// 某個來源的位置。 +@immutable +final class SourceProgress { + const SourceProgress({required this.sourceId, required this.progress}); + + final int sourceId; + final PlaybackProgress progress; +} + +/// 後端的事件。 +@immutable +sealed class BackendEvent { + const BackendEvent(); +} + +/// 前瞻接上了:[from] 結束([end]),[to] 成為目前的來源。 +final class SourceAdvanced extends BackendEvent { + const SourceAdvanced({ + required this.from, + required this.to, + required this.end, + }); + + final int from; + final int to; + final TrackEndReason end; +} + +/// 目前的來源結束,沒有前瞻可接。 +final class SourceEnded extends BackendEvent { + const SourceEnded({required this.id, required this.end}); + + final int id; + final TrackEndReason end; +} + +/// 來源失敗。後端不自己跳到下一個。 +final class SourceFailed extends BackendEvent { + const SourceFailed({required this.id, required this.failure, this.cause}); + + final int id; + final BackendFailure failure; + + /// 引擎給的原始錯誤,可能帶完整的串流網址:只以 `error` 交給 log 門面 + /// (經 `Redactor` 遮蔽),不放進訊息或畫面。 + final Object? cause; +} + +enum BackendFailure { + /// 還沒載入就失敗:開不起來、格式解不了。上層換下一個候選(ADR 0018 + /// §決定 7)。 + open, + + /// 已經在播之後中斷。上層從目前位置重試。 + interrupted, +} diff --git a/app/lib/playback/backends/audio_backends.dart b/app/lib/playback/backends/audio_backends.dart new file mode 100644 index 00000000..650d9335 --- /dev/null +++ b/app/lib/playback/backends/audio_backends.dart @@ -0,0 +1,12 @@ +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/just_audio_backend.dart'; +import 'package:fmp/playback/backends/media_kit_backend.dart'; + +/// 依平台宣告的 [kind] 建立後端。只有這裡與兩個實作檔知道具體的引擎。 +AudioBackend createAudioBackend(AudioBackendKind kind, {required Log log}) => + switch (kind) { + AudioBackendKind.justAudio => JustAudioBackend(log: log), + AudioBackendKind.mediaKit => MediaKitBackend.create(log: log), + }; diff --git a/app/lib/playback/backends/backend_rules.dart b/app/lib/playback/backends/backend_rules.dart new file mode 100644 index 00000000..555730ea --- /dev/null +++ b/app/lib/playback/backends/backend_rules.dart @@ -0,0 +1,77 @@ +// 兩個後端共用的規則(ADR 0018 §決定 3):不含任何引擎型別的純函數。 +// +// 翻譯仍在後端:只有後端知道自己面對的是 ExoPlayer 還是 mpv。搬到這裡的是規則 +// 本身,兩個真後端在 `flutter test` 裡建不起來(just_audio 要 platform channel, +// media_kit 要 libmpv),規則是純函數,「同一份斷言跑在兩個實作與假後端上」才 +// 做得到:`test/playback/backends/backend_rules_test.dart` 測規則, +// `audio_backend_contract.dart` 以同一份行為斷言跑假後端(`flutter test`)與 +// 平台的真後端(`integration_test/audio_backend_contract_test.dart`)。 +// +// 形狀照舊專案的 `playback_end_reason_rules.dart`、`next_media_plan.dart`。 + +/// 一個來源為什麼停下來。 +enum TrackEndReason { + /// 播到結尾。 + completed, + + /// 離結尾還遠就停了,或引擎從沒回報過時長:串流中斷、零位元組的回應。 + /// 上層當成傳輸中斷,從停下的位置重試(ADR 0018 §決定 7)。 + endedEarly, +} + +/// 「位置離時長還差這麼多以上」就算提前結束。兩個引擎的位置回報都有間隔 +/// (ExoPlayer 的事件外推、mpv 的 `time-pos`),1.5 秒容得下,舊專案同值。 +const completionTolerance = Duration(milliseconds: 1500); + +/// 引擎說目前的來源結束了(播完或接到前瞻)時,判斷是真的播完還是提前結束。 +/// +/// [position] 是結束前最後的位置,[duration] 是引擎回報的時長;`null` 或 0 是 +/// 引擎從沒回報過時長,舊專案實測「連得上但零位元組」的串流就是這個形狀, +/// 所以算提前結束,不當成播完直接接下一首。 +TrackEndReason classifyTrackEnd({ + required Duration position, + required Duration? duration, +}) { + if (duration == null || duration <= Duration.zero) { + return TrackEndReason.endedEarly; + } + if (duration - position > completionTolerance) { + return TrackEndReason.endedEarly; + } + return TrackEndReason.completed; +} + +/// 讓後端的播放清單回到「目前+最多一個前瞻」要做的修改。 +final class LookAheadEdit { + const LookAheadEdit._({required this.removeIndices, required this.append}); + + /// 清單裡有 [itemCount] 個項目、正在播 [currentIndex];[append] 是要不要接上 + /// 新的前瞻(`false` 是清掉)。 + /// + /// 目前這個項目以外的全部移掉:之後的是舊的前瞻,之前的是剛播完的那一首 + /// (接上前瞻後由後端自己修剪)。留著的話每個項目都是一條開著的連線,而且 + /// 目前的項目不在 0,「下一個」就不一定是 1。清單是空的就什麼都不做:沒有 + /// 東西在播,也就沒有可以接上去的位置。 + factory LookAheadEdit.of({ + required int itemCount, + required int currentIndex, + required bool append, + }) { + if (itemCount <= 0 || currentIndex < 0 || currentIndex >= itemCount) { + return const LookAheadEdit._(removeIndices: [], append: false); + } + return LookAheadEdit._( + removeIndices: [ + for (var index = itemCount - 1; index >= 0; index--) + if (index != currentIndex) index, + ], + append: append, + ); + } + + /// 要移除的索引,由大到小:照這個順序移,前面的索引不會位移。 + final List removeIndices; + + /// 移完後要不要把新的前瞻接在目前的項目之後(成為索引 1)。 + final bool append; +} diff --git a/app/lib/playback/backends/just_audio_backend.dart b/app/lib/playback/backends/just_audio_backend.dart new file mode 100644 index 00000000..78d92ee2 --- /dev/null +++ b/app/lib/playback/backends/just_audio_backend.dart @@ -0,0 +1,329 @@ +import 'dart:async'; + +import 'package:just_audio/just_audio.dart'; + +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; +import 'package:fmp/playback/playback_state.dart'; + +/// [AudioBackend] 的 just_audio(ExoPlayer)實作:Android。 +/// +/// 整個 App 一個 `AudioPlayer`,換來源只換清單(音訊焦點見 [AudioBackend])。 +/// 每個 just_audio 項目的 `tag` 是 [BackendSource.id],索引以它對回來源。 +final class JustAudioBackend implements AudioBackend { + JustAudioBackend({required this._log}) + : _player = AudioPlayer( + // 標頭直接交給 ExoPlayer(DataSource 的 request properties),不開 + // just_audio 的本機明文 proxy。 + useProxyForRequestHeaders: false, + // 第二個項目(前瞻)一接上就預備,交接處才不用等開流。 + useLazyPreparation: false, + ) { + _subscriptions + ..add(_player.playerStateStream.listen(_onPlayerState)) + ..add(_player.playbackEventStream.listen(_onPlaybackEvent)) + ..add(_player.errorStream.listen(_onError)) + ..add(_player.positionStream.listen(_onPosition)); + } + + final Log _log; + final AudioPlayer _player; + final _subscriptions = >[]; + + final _status = StreamController.broadcast(); + final _progress = StreamController.broadcast(); + final _events = StreamController.broadcast(); + + int? _currentId; + int? _nextId; + + /// 目前的來源已經載入(有時長或位置);之前的失敗算 [BackendFailure.open]。 + bool _loaded = false; + + /// 目前的來源已經發過結束或失敗,不再發第二次。 + bool _settled = false; + + /// 目前來源最後一次的事件,用來外推交接時的位置。 + PlaybackEvent? _lastEvent; + + /// 使用者要不要出聲:[open] 的 `play`、[play]、[pause] 設定。載入中被暫停時, + /// 載入完不能再以 [open] 當時的 `play` 開始播。 + bool _wantPlaying = false; + + /// 清單正在修改:事件裡的索引可能還是舊的,改完再核對一次。 + int _editing = 0; + Future _edits = Future.value(); + + @override + Stream get status => _status.stream; + + @override + Stream get progress => _progress.stream; + + @override + Stream get events => _events.stream; + + @override + Future open( + BackendSource source, { + Duration start = Duration.zero, + bool play = true, + }) { + _currentId = source.id; + _nextId = null; + _wantPlaying = play; + _resetCurrent(); + return _edit(() async { + if (_currentId != source.id) return; + // just_audio 的 playing 跨來源保留:要停在暫停就先暫停,否則一載入就播。 + if (!_wantPlaying) await _player.pause(); + try { + await _player.setAudioSources([ + _audioSource(source), + ], initialPosition: start); + } on PlayerInterruptedException { + // 被下一次 open 取代;新的來源自己會回報。 + return; + } on Object catch (error) { + // PlayerException,或 asset 找不到之類在交給 ExoPlayer 前就拋的錯誤。 + _fail(source.id, BackendFailure.open, error); + return; + } + if (_wantPlaying && _currentId == source.id) { + // play() 的 Future 要到暫停才完成,不能 await。 + unawaited(_player.play()); + } + }); + } + + @override + Future setNext(BackendSource? next) { + final owner = _currentId; + if (owner == null) return Future.value(); + _nextId = next?.id; + return _edit(() async { + // 排隊的期間換了來源(open、交接):這次修改已經過時。 + if (_currentId != owner) return; + final edit = LookAheadEdit.of( + itemCount: _player.sequence.length, + currentIndex: _player.currentIndex ?? -1, + append: next != null, + ); + for (final index in edit.removeIndices) { + await _player.removeAudioSourceAt(index); + } + if (edit.append && next != null && _nextId == next.id) { + await _player.addAudioSource(_audioSource(next)); + } + }); + } + + @override + Future play() async { + _wantPlaying = true; + unawaited(_player.play()); + } + + @override + Future pause() { + _wantPlaying = false; + return _player.pause(); + } + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future stop() { + _currentId = null; + _nextId = null; + _wantPlaying = false; + _resetCurrent(); + return _edit(() async { + if (_currentId != null) return; + await _player.stop(); + await _player.clearAudioSources(); + }); + } + + @override + Future dispose() async { + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await _player.dispose(); + await _status.close(); + await _progress.close(); + await _events.close(); + } + + AudioSource _audioSource(BackendSource source) => AudioSource.uri( + source.url, + headers: source.headers.isEmpty ? null : source.headers, + tag: source.id, + ); + + void _resetCurrent() { + _loaded = false; + _settled = false; + _lastEvent = null; + } + + /// 清單的修改一個接一個做:每次都依當下的清單算 [LookAheadEdit],交接後的 + /// 修剪與控制器的下一次 [setNext] 才不會以同一份舊清單各算一次。 + Future _edit(Future Function() change) { + final done = _edits.then((_) async { + _editing++; + try { + await change(); + } finally { + _editing--; + // 修改期間的事件都丟掉了:以最後一個事件補一次。暫停中載入好的來源 + // 之後不會再有事件(播放中還有 play 帶來的那一個),不補就停在緩衝。 + if (_editing == 0) _onPlaybackEvent(_player.playbackEvent); + } + }); + _edits = done.catchError(_logEditError); + return done; + } + + int? _idAt(int? index) { + final sequence = _player.sequence; + if (index == null || index < 0 || index >= sequence.length) return null; + return switch (sequence[index].tag) { + final int id => id, + _ => null, + }; + } + + void _onPlayerState(PlayerState state) => _emitStatus(); + + void _emitStatus() { + final state = _player.playerState; + final id = _currentId; + final phase = switch (state.processingState) { + _ when id == null => BackendPhase.idle, + ProcessingState.idle => BackendPhase.idle, + ProcessingState.loading || + ProcessingState.buffering => BackendPhase.buffering, + // 狀態可能還是上一個來源的:目前的來源載入前都算緩衝。 + ProcessingState.ready => + _loaded ? BackendPhase.ready : BackendPhase.buffering, + ProcessingState.completed => BackendPhase.ended, + }; + _add( + _status, + BackendStatus( + sourceId: id, + playing: state.playing && phase != BackendPhase.ended && id != null, + phase: phase, + ), + ); + } + + void _onPlaybackEvent(PlaybackEvent event) { + if (_editing > 0) return; + _checkCurrentSource(); + if (_idAt(event.currentIndex) != _currentId) return; + _lastEvent = event; + if (!_loaded && + event.processingState == ProcessingState.ready && + (event.duration != null || event.updatePosition > Duration.zero)) { + _loaded = true; + _emitStatus(); + } + final id = _currentId; + if (event.processingState == ProcessingState.completed && + id != null && + !_settled) { + _settled = true; + _add( + _events, + SourceEnded( + id: id, + end: classifyTrackEnd( + position: event.updatePosition, + duration: event.duration, + ), + ), + ); + } + } + + /// ExoPlayer 自己換到前瞻時,目前的索引指到 [_nextId]。 + void _checkCurrentSource() { + final current = _currentId; + final next = _nextId; + if (current == null || next == null) return; + if (_idAt(_player.currentIndex) != next) return; + final end = classifyTrackEnd( + position: _positionAtHandover(), + duration: _lastEvent?.duration, + ); + _currentId = next; + _nextId = null; + _resetCurrent(); + // 前瞻是預備好才接上的。 + _loaded = true; + _add(_events, SourceAdvanced(from: current, to: next, end: end)); + _emitStatus(); + // 播完的那一個移掉,清單回到只有目前這一個。 + unawaited(setNext(null).catchError(_logEditError)); + } + + /// 前一個來源交接時的位置:最後一次事件的位置,播放中就外推到現在。 + Duration _positionAtHandover() { + final event = _lastEvent; + if (event == null) return Duration.zero; + var position = event.updatePosition; + if (_player.playing && event.processingState == ProcessingState.ready) { + position += DateTime.now().difference(event.updateTime); + } + final duration = event.duration; + return duration != null && position > duration ? duration : position; + } + + void _onError(PlayerException error) { + final id = _idAt(error.index) ?? _currentId; + if (id == null || id != _currentId) return; + _fail( + id, + _loaded ? BackendFailure.interrupted : BackendFailure.open, + error, + ); + } + + void _fail(int id, BackendFailure failure, Object cause) { + if (id != _currentId || _settled) return; + _settled = true; + _add(_events, SourceFailed(id: id, failure: failure, cause: cause)); + } + + void _onPosition(Duration position) { + final id = _currentId; + if (id == null || !_loaded) return; + _add( + _progress, + SourceProgress( + sourceId: id, + progress: PlaybackProgress( + position: position, + duration: _player.duration, + buffered: _player.bufferedPosition, + ), + ), + ); + } + + void _logEditError(Object error, StackTrace stackTrace) => _log.warning( + 'Failed to edit the playlist', + tag: 'playback', + error: error, + stackTrace: stackTrace, + ); + + static void _add(StreamController controller, T value) { + if (!controller.isClosed) controller.add(value); + } +} diff --git a/app/lib/playback/backends/media_kit_backend.dart b/app/lib/playback/backends/media_kit_backend.dart new file mode 100644 index 00000000..868ddfc3 --- /dev/null +++ b/app/lib/playback/backends/media_kit_backend.dart @@ -0,0 +1,351 @@ +import 'dart:async'; + +import 'package:media_kit/media_kit.dart'; + +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; +import 'package:fmp/playback/playback_state.dart'; + +/// [AudioBackend] 的 media_kit(libmpv)實作:Windows。libmpv 由 +/// `media_kit_libs_windows_audio` 在建置時下載、放在執行檔旁。 +/// +/// 播放清單裡的 `Media` 是這裡建的實例(media_kit 的清單保留原物件),以 +/// [Expando] 對回 [BackendSource.id];不用網址對,因為不同來源可以是同一個網址。 +final class MediaKitBackend implements AudioBackend { + MediaKitBackend._(this._log, this._player) { + _ready = _configure(); + final stream = _player.stream; + _subscriptions + ..add(stream.playlist.listen(_onPlaylist)) + ..add(stream.playing.listen((_) => _emitStatus())) + ..add(stream.buffering.listen((_) => _emitStatus())) + ..add(stream.completed.listen(_onCompleted)) + ..add(stream.duration.listen(_onDuration)) + ..add(stream.position.listen(_onPosition)) + ..add(stream.error.listen(_onError)); + } + + /// 第一次建立時載入 libmpv(`MediaKit.ensureInitialized`);找不到就拋錯。 + factory MediaKitBackend.create({required Log log}) { + MediaKit.ensureInitialized(); + return MediaKitBackend._( + log, + // title 是 Windows 音量混合器裡顯示的名稱。 + Player(configuration: const PlayerConfiguration(title: 'FMP')), + ); + } + + final Log _log; + final Player _player; + late final Future _ready; + final _subscriptions = >[]; + final _sourceIds = Expando('BackendSource.id'); + + final _status = StreamController.broadcast(); + final _progress = StreamController.broadcast(); + final _events = StreamController.broadcast(); + + int? _currentId; + int? _nextId; + + /// 目前的來源已經載入(有時長或位置);之前的錯誤算 [BackendFailure.open]。 + bool _loaded = false; + bool _settled = false; + + /// 目前的來源是接上的前瞻:mpv 已經預先開好,載入前不必回報緩衝。 + bool _handedOver = false; + + /// [open] 之後、`Player.open` 回來之前。這段期間收到的位置、時長、結束與 + /// 錯誤還是上一個檔案的:media_kit 的事件不帶項目,`Player.open` 內的 mpv + /// 指令是非同步的,等待時會先處理已經排著的舊事件。 + bool _opening = false; + + /// 目前來源回報過的最大位置與時長。用最大值:mpv 換檔時可能先回報新檔的 + /// `time-pos`,才換 `playlist-playing-pos`。 + Duration _position = Duration.zero; + Duration? _duration; + + Future _edits = Future.value(); + + /// 使用者要不要出聲:[open] 的 `play`、[play]、[pause] 設定。 + bool _wantPlaying = false; + + @override + Stream get status => _status.stream; + + @override + Stream get progress => _progress.stream; + + @override + Stream get events => _events.stream; + + /// mpv 的選項:預設是影片播放器,關掉影像;清單的第二個項目提早開流 + /// (預設 `no`),交接處才不用等。mpv 手冊說 `prefetch-playlist` 在用了 + /// per-file 選項時可能不準,media_kit 的標頭正是 per-file(`on_load` hook); + /// 舊專案以只認正確 Referer 的本機伺服器實測過,前瞻開流帶的標頭是對的。 + Future _configure() async { + final platform = _player.platform; + if (platform is! NativePlayer) return; + await platform.setProperty('vid', 'no'); + await platform.setProperty('prefetch-playlist', 'yes'); + } + + @override + Future open( + BackendSource source, { + Duration start = Duration.zero, + bool play = true, + }) { + _currentId = source.id; + _nextId = null; + _wantPlaying = play; + _resetCurrent(); + _opening = true; + return _edit(() async { + if (_currentId != source.id) return; + try { + // 排隊期間的 pause/play 以當下的意願為準。 + await _player.open(_media(source, start: start), play: _wantPlaying); + } on Object catch (error) { + _fail(source.id, BackendFailure.open, error); + } finally { + if (_currentId == source.id) _opening = false; + } + }); + } + + @override + Future setNext(BackendSource? next) { + final owner = _currentId; + if (owner == null) return Future.value(); + _nextId = next?.id; + return _edit(() async { + if (_currentId != owner) return; + final playlist = _player.state.playlist; + final edit = LookAheadEdit.of( + itemCount: playlist.medias.length, + currentIndex: playlist.index, + append: next != null, + ); + for (final index in edit.removeIndices) { + await _player.remove(index); + } + if (edit.append && next != null && _nextId == next.id) { + await _player.add(_media(next)); + } + }); + } + + @override + Future play() async { + _wantPlaying = true; + await _ready; + await _player.play(); + _emitStatus(); + } + + @override + Future pause() async { + _wantPlaying = false; + await _ready; + await _player.pause(); + _emitStatus(); + } + + @override + Future seek(Duration position) async { + await _ready; + _position = position; + await _player.seek(position); + } + + @override + Future stop() { + _currentId = null; + _nextId = null; + _wantPlaying = false; + _resetCurrent(); + return _edit(() async { + if (_currentId != null) return; + await _player.stop(); + }); + } + + @override + Future dispose() async { + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await _player.dispose(); + await _status.close(); + await _progress.close(); + await _events.close(); + } + + Media _media(BackendSource source, {Duration start = Duration.zero}) { + final media = Media( + source.url.toString(), + httpHeaders: source.headers.isEmpty ? null : source.headers, + start: start > Duration.zero ? start : null, + ); + _sourceIds[media] = source.id; + return media; + } + + void _resetCurrent() { + _loaded = false; + _settled = false; + _handedOver = false; + _position = Duration.zero; + _duration = null; + } + + /// 清單的修改一個接一個做,每次依當下的清單算 [LookAheadEdit]。 + Future _edit(Future Function() change) { + final done = _edits.then((_) async { + await _ready; + await change(); + }); + _edits = done.catchError(_logEditError); + return done; + } + + void _onPlaylist(Playlist playlist) { + final index = playlist.index; + if (index < 0 || index >= playlist.medias.length) return; + final id = _sourceIds[playlist.medias[index]]; + final current = _currentId; + if (id == null || current == null || id == current || id != _nextId) { + return; + } + final end = classifyTrackEnd(position: _position, duration: _duration); + _currentId = id; + _nextId = null; + _resetCurrent(); + _handedOver = true; + // mpv 的屬性只在值改變時發事件:兩個檔案一樣長時,接上後不會再收到 + // duration,所以先沿用引擎現在的值,不一樣時事件會再更新。 + final duration = _player.state.duration; + if (duration > Duration.zero) _duration = duration; + _add(_events, SourceAdvanced(from: current, to: id, end: end)); + _emitStatus(); + // 播完的那一個移掉,清單回到只有目前這一個。 + unawaited(setNext(null).catchError(_logEditError)); + } + + /// mpv 換檔的瞬間,`playing`、`buffering` 會閃一下(檔尾的 eof、下一個檔的 + /// START_FILE),而且那時目前的來源可能還是舊的。所以 `playing` 報的是使用者 + /// 要不要出聲([_wantPlaying],與 just_audio 的 `playing` 同義);檔尾 + /// [completionTolerance] 之內、以及接上的前瞻回報位置之前,不報緩衝。 + void _emitStatus() { + final state = _player.state; + final id = _currentId; + final duration = _duration; + final nearEnd = + state.completed || + (duration != null && duration - _position <= completionTolerance); + final settling = _handedOver && !_loaded; + final phase = switch (id) { + null => BackendPhase.idle, + _ when _opening => BackendPhase.buffering, + _ when state.completed && _nextId == null => BackendPhase.ended, + // open 之後、檔案載入前,mpv 的旗標還沒反映新檔:載入前都算緩衝。 + _ when !_loaded && !_handedOver => BackendPhase.buffering, + _ when state.buffering && !nearEnd && !settling => BackendPhase.buffering, + _ => BackendPhase.ready, + }; + _add( + _status, + BackendStatus( + sourceId: id, + playing: _wantPlaying && phase != BackendPhase.ended, + phase: phase, + ), + ); + } + + void _onCompleted(bool completed) { + _emitStatus(); + if (_opening) return; + final id = _currentId; + // 有前瞻時 mpv 會接著播下一個(接上時由 _onPlaylist 回報),這裡的 + // completed 只是換檔的瞬間。 + if (!completed || id == null || _nextId != null || _settled) return; + _settled = true; + _add( + _events, + SourceEnded( + id: id, + end: classifyTrackEnd(position: _position, duration: _duration), + ), + ); + } + + void _onDuration(Duration duration) { + if (_currentId == null || _opening || duration <= Duration.zero) return; + _duration = duration; + _markLoaded(); + } + + void _onPosition(Duration position) { + final id = _currentId; + if (id == null || _opening) return; + if (position > _position) _position = position; + if (position > Duration.zero) _markLoaded(); + if (!_loaded) return; + _add( + _progress, + SourceProgress( + sourceId: id, + progress: PlaybackProgress( + position: position, + duration: _duration, + buffered: _player.state.buffer, + ), + ), + ); + } + + void _markLoaded() { + if (_loaded) return; + _loaded = true; + _emitStatus(); + } + + /// mpv 的錯誤只是一行 log,不帶項目:還沒載入就算開不起來;已經在播的 + /// 錯誤不在這裡處理,mpv 停下時由 [_onCompleted] 依位置判斷是否提前結束。 + /// + /// 這行字可能帶完整的簽名網址(例如 `ffmpeg: Opening '…/videoplayback?…'`): + /// 只以 `error` 交給 log 門面(經 `Redactor` 遮蔽),不自己印、不放進訊息。 + void _onError(String message) { + final id = _currentId; + if (id == null || _opening) return; + if (_loaded) { + _log.warning( + 'Audio engine reported an error', + tag: 'playback', + error: message, + ); + return; + } + _fail(id, BackendFailure.open, message); + } + + void _fail(int id, BackendFailure failure, Object cause) { + if (id != _currentId || _settled) return; + _settled = true; + _add(_events, SourceFailed(id: id, failure: failure, cause: cause)); + } + + void _logEditError(Object error, StackTrace stackTrace) => _log.warning( + 'Failed to edit the playlist', + tag: 'playback', + error: error, + stackTrace: stackTrace, + ); + + static void _add(StreamController controller, T value) { + if (!controller.isClosed) controller.add(value); + } +} diff --git a/app/lib/playback/dev_playback_entry.dart b/app/lib/playback/dev_playback_entry.dart new file mode 100644 index 00000000..12e6d965 --- /dev/null +++ b/app/lib/playback/dev_playback_entry.dart @@ -0,0 +1,86 @@ +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/plugins/install/dev_plugin_entry.dart'; +import 'package:fmp/plugins/install/plugin_installer.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; + +// 播放的開發入口(M1 PR 10 第 7 項):啟動時直接播一個清單,給還沒有播放 UI +// (PR 12)的這段期間在實機上驗證前瞻交接與 Android 的音訊焦點。PR 12 的播放列 +// 能走同一條路之後刪掉;交接與出聲的 log 留在 PlaybackController。 +// +// 只在 dev flavor 生效([devPlaybackRequest]):prod 不讀參數,理由同插件的 +// 開發入口(dev_plugin_entry.dart)——它會安裝插件。 + +/// 命令列參數。只寫 `--fmp-dev-playback` 是播內附測試插件的三個音檔; +/// `--fmp-dev-playback=<曲目鍵>` 播指定的曲目,要幾首就重複幾次(插件要已安裝, +/// 或同時以 `--fmp-dev-plugin` 安裝)。不用逗號分隔:Android 的 +/// `am start --esal` 以逗號切陣列。 +const devPlaybackArgument = '--fmp-dev-playback'; + +/// 內附測試插件的安裝檔(dev flavor 的 asset)。 +const devTestPluginAsset = 'test/fixtures/plugins/test_plugin/test_plugin.js'; + +/// 測試插件的三首(`resolveStream` 都回同一個 2 秒的音檔)。 +const devTestTracks = ['tone-220', 'tone-440', 'tone-880']; + +/// 從 [arguments] 找播放的開發入口:沒有、或 [flavor] 不是 dev 就是 `null`; +/// 只有旗標是空清單;否則是依序的每個值。 +List? devPlaybackRequest(AppFlavor flavor, List arguments) { + if (flavor != AppFlavor.dev) return null; + List? values; + for (final argument in arguments) { + if (argument == devPlaybackArgument) { + values ??= []; + } else if (argument.startsWith('$devPlaybackArgument=')) { + (values ??= []).add(argument.substring(devPlaybackArgument.length + 1)); + } + } + return values; +} + +/// 把 `--fmp-dev-playback=` 的值轉成曲目鍵;有任何一個格式不對就拋 +/// [FormatException]。 +List parseDevPlaybackTracks(List values) => [ + for (final raw in values) + TrackKey.tryParse(raw.trim()) ?? + (throw FormatException('Not a track key', raw)), +]; + +/// `main()` 以 [devPlaybackRequest] override;預設 `null`(不播)。 +final devPlaybackRequestProvider = Provider?>((ref) => null); + +/// 播放的開發入口:照 [devPlaybackRequestProvider] 開始播放;沒有要求時什麼都 +/// 不做。 +final devPlaybackProvider = FutureProvider((ref) async { + final request = ref.watch(devPlaybackRequestProvider); + if (request == null) return; + final List tracks; + if (request.isEmpty) { + final plugin = await ref + .watch(pluginInstallerProvider) + .installSource(await rootBundle.loadString(devTestPluginAsset)); + tracks = [ + for (final sourceId in devTestTracks) + TrackKeyParts(sourceTypeId: plugin.manifest.id, sourceId: sourceId), + ]; + } else { + tracks = parseDevPlaybackTracks(request); + await ref.watch(devPluginInstallProvider.future); + await ref.watch(pluginRegistryProvider.future); + } + ref + .watch(logProvider) + .info( + 'Development playback started', + tag: 'playback', + fields: { + 'tracks': [for (final track in tracks) '$track'], + }, + ); + await ref.watch(playbackControllerProvider).playQueue(tracks); +}); diff --git a/app/lib/playback/playback_controller.dart b/app/lib/playback/playback_controller.dart new file mode 100644 index 00000000..210667c6 --- /dev/null +++ b/app/lib/playback/playback_controller.dart @@ -0,0 +1,700 @@ +import 'dart:async'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/playback/queue_model.dart'; +import 'package:fmp/playback/recovery_policy.dart'; +import 'package:fmp/playback/stream_resolver.dart'; + +/// UI 唯一的播放入口(ADR 0018 §決定 1),也是 [PlaybackState] 唯一的寫入者。 +/// +/// 協作者只回報:[QueueModel] 保管佇列、[StreamResolver] 解析、 +/// [decideRecovery] 決定失敗後怎麼辦、[AudioBackend] 播放並回報事件;狀態只在 +/// 這裡依它們的結果改。M1 只有播放一個清單、播放與暫停、上一首與下一首、seek。 +/// +/// 前瞻(ADR 0018 §決定 3、6):目前這首載入好之後,解析下一首一次、交給後端 +/// 的 [AudioBackend.setNext];後端自己接上([SourceAdvanced]),接上的那首不再 +/// 解析。候選有期限時,在過期前([ResolvedStream.expiryMargin])重新解析並換掉 +/// 前瞻;手動下一首時也先檢查。 +/// +/// 每次開始一首(含重試、換候選、接上前瞻)都換一個「代」:還在進行的解析 +/// 回來時代已經不同,結果就丟掉。插件的 `resolveStream` 沒有取消參數,M1 不 +/// 取消網路工作(ADR 0018 §決定 6 的取消在插件 API 支援後接上)。 +final class PlaybackController { + PlaybackController({ + required this._backend, + required this._resolver, + required this._log, + this._now = DateTime.now, + }) { + _subscriptions + ..add(_backend.status.listen(_onStatus)) + ..add(_backend.progress.listen(_onProgress)) + ..add(_backend.events.listen(_onEvent)); + } + + static const _tag = 'playback'; + + final AudioBackend _backend; + final StreamResolver _resolver; + final Log _log; + final DateTime Function() _now; + final _subscriptions = >[]; + + final _queue = QueueModel(); + final _states = StreamController.broadcast(); + final _queueStates = StreamController.broadcast(); + final _progress = StreamController.broadcast(); + + PlaybackState _state = const Idle(); + + int _generation = 0; + int _lastSourceId = 0; + + /// 交給後端的目前來源;解析中、等重試、停下時為 `null`。 + _Current? _current; + _LookAhead? _lookAhead; + + /// 已經為哪一代要求過前瞻(每首只解析一次)。 + int? _lookAheadFor; + + /// 使用者要不要出聲:暫停中開始的一首載入後停在暫停。 + bool _playWhenReady = true; + + /// 等重試或暫停時,下次從哪裡開始。 + Duration _resumeAt = Duration.zero; + Timer? _retryTimer; + + // RecoveryPolicy 的計數,只在這裡改。 + int _retries = 0; + bool _candidateSwitched = false; + int _consecutiveSkips = 0; + + // 交接的量測(實機驗證用,見 app/AGENTS.md § 播放)。 + DateTime? _requestedAt; + _Handover? _handover; + DateTime? _lastProgressAt; + + PlaybackState get state => _state; + + /// 狀態的變化(只在改變時發出)。 + Stream get states => _states.stream; + + QueueState get queue => _queue.state; + + Stream get queueStates => _queueStates.stream; + + /// 目前這首的位置、時長與緩衝。 + Stream get progress => _progress.stream; + + /// 以 [tracks] 取代佇列,從 [startIndex] 開始播。 + Future playQueue(List tracks, {int startIndex = 0}) { + _queue.replace(tracks, startIndex: startIndex); + _emitQueue(); + _consecutiveSkips = 0; + _playWhenReady = true; + if (_queue.state.current == null) return _stopWith(const Idle()); + return _beginTrack(); + } + + Future play() async { + _playWhenReady = true; + switch (_state) { + case Paused() || Loading() || Buffering(): + if (_current != null) { + await _backend.play(); + } else if (_state is Paused) { + // 等重試時被暫停:重新開始這一首。 + await _load(position: _resumeAt); + } + case Idle() || Failed(): + if (_queue.state.current == null) return; + _consecutiveSkips = 0; + await _beginTrack(); + case Playing() || Retrying(): + return; + } + } + + Future pause() async { + _playWhenReady = false; + switch (_state) { + case Playing() || Buffering() || Loading(): + if (_current != null) await _backend.pause(); + case Retrying(): + _cancelRetry(); + _setState(const Paused()); + case Paused() || Idle() || Failed(): + return; + } + } + + /// 下一首;已經是最後一首就不動。 + Future next() { + final next = _queue.next; + if (next == null) return Future.value(); + final prepared = _takeLookAhead(next.index); + _queue.moveNext(); + _emitQueue(); + _consecutiveSkips = 0; + return _beginTrack(prepared: prepared); + } + + /// 上一首;已經是第一首就回到這首的開頭。 + Future previous() { + if (!_queue.movePrevious()) return seek(Duration.zero); + _emitQueue(); + _consecutiveSkips = 0; + return _beginTrack(); + } + + Future seek(Duration position) async { + if (_current == null) { + _resumeAt = position; + return; + } + await _backend.seek(position); + } + + Future dispose() async { + // 換一代:還在進行的解析回來時不再開流或設定前瞻。 + _generation++; + _current = null; + _cancelRetry(); + _clearLookAhead(); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await _states.close(); + await _queueStates.close(); + await _progress.close(); + } + + // ---- 開始一首 ------------------------------------------------------------- + + /// 換到佇列目前這首:重設這首的恢復計數。 + Future _beginTrack({ResolvedStream? prepared}) { + _retries = 0; + _candidateSwitched = false; + return _load(prepared: prepared); + } + + /// 解析(或用 [prepared])並交給後端,從 [position] 開始。 + Future _load({ + Duration position = Duration.zero, + ResolvedStream? prepared, + }) async { + final track = _queue.state.current; + if (track == null) return; + final generation = ++_generation; + _cancelRetry(); + _clearLookAhead(); + _current = null; + _handover = null; + _resumeAt = position; + _requestedAt = _now(); + _setState(const Loading()); + _log.info( + 'Track requested', + tag: _tag, + fields: {'track': '$track', 'queueIndex': _queue.state.currentIndex}, + ); + + final ResolvedStream stream; + if (prepared != null && prepared.isFreshAt(_now())) { + stream = prepared; + } else { + if (prepared != null) { + _log.info( + 'Look-ahead expired; resolving again', + tag: _tag, + fields: {'track': '$track'}, + ); + } + // 解析期間不讓上一首繼續出聲。 + unawaited(_backend.stop()); + try { + stream = await _resolver.resolve(track); + } on AppError catch (error) { + if (generation != _generation) return; + _log.report('Stream resolution failed', error, tag: _tag); + return _recover(ResolveFailed(error), error, position); + } + if (generation != _generation) return; + } + // 解析期間的 seek 記在 _resumeAt。 + await _open(stream, candidate: 0, position: _resumeAt); + } + + Future _open( + ResolvedStream stream, { + required int candidate, + required Duration position, + }) async { + final generation = _generation; + final chosen = stream.candidates[candidate]; + final source = BackendSource( + id: ++_lastSourceId, + url: chosen.url, + headers: chosen.headers, + ); + _current = _Current( + generation: generation, + sourceId: source.id, + stream: stream, + candidate: candidate, + ); + _log.info( + 'Opening stream', + tag: _tag, + fields: { + 'track': '${stream.track}', + 'candidate': candidate, + 'container': ?chosen.container, + 'codec': ?chosen.codec, + // 只記名稱:值可能是 User-Agent 以外的識別資訊。 + 'headers': source.headers.keys.toList(), + }, + ); + await _backend.open(source, start: position, play: _playWhenReady); + } + + // ---- 前瞻 ----------------------------------------------------------------- + + /// 為目前這一代解析下一首一次,交給後端。 + Future _prepareLookAhead() async { + final current = _current; + if (current == null || _lookAheadFor == current.generation) return; + _lookAheadFor = current.generation; + final next = _queue.next; + if (next == null) return; + final ResolvedStream stream; + try { + stream = await _resolver.resolve(next.track); + } on AppError catch (error) { + // 到那一首時再依錯誤處理(重新解析一次)。 + _log.report('Look-ahead resolution failed', error, tag: _tag); + return; + } + if (_current?.generation != current.generation || + _queue.next?.index != next.index) { + return; + } + await _setLookAhead(next.index, stream); + } + + Future _setLookAhead(int queueIndex, ResolvedStream stream) async { + _clearLookAhead(); + final chosen = stream.candidates.first; + final source = BackendSource( + id: ++_lastSourceId, + url: chosen.url, + headers: chosen.headers, + ); + final lookAhead = _lookAhead = _LookAhead( + queueIndex: queueIndex, + stream: stream, + sourceId: source.id, + ); + final refreshAt = stream.refreshAt; + if (refreshAt != null) { + final delay = refreshAt.difference(_now()); + // 一解析出來就快過期的不排:手動下一首時會再檢查。 + if (delay > Duration.zero) { + lookAhead.refresh = Timer(delay, () => _refreshLookAhead(lookAhead)); + } + } + _log.info( + 'Look-ahead prepared', + tag: _tag, + fields: {'track': '${stream.track}'}, + ); + await _backend.setNext(source); + } + + /// 前瞻的網址快過期了:重新解析並換掉。 + Future _refreshLookAhead(_LookAhead lookAhead) async { + if (!identical(_lookAhead, lookAhead)) return; + final ResolvedStream stream; + try { + stream = await _resolver.resolve(lookAhead.stream.track); + } on AppError catch (error) { + _log.report('Look-ahead refresh failed', error, tag: _tag); + return; + } + if (!identical(_lookAhead, lookAhead)) return; + _log.info( + 'Look-ahead refreshed before expiry', + tag: _tag, + fields: {'track': '${stream.track}'}, + ); + await _setLookAhead(lookAhead.queueIndex, stream); + } + + /// 取走位置 [queueIndex] 的前瞻解析結果(手動下一首、跳過時沿用)。 + ResolvedStream? _takeLookAhead(int queueIndex) { + final lookAhead = _lookAhead; + if (lookAhead == null || lookAhead.queueIndex != queueIndex) return null; + _clearLookAhead(); + return lookAhead.stream; + } + + void _clearLookAhead() { + _lookAhead?.refresh?.cancel(); + _lookAhead = null; + } + + // ---- 後端回報 ------------------------------------------------------------- + + void _onStatus(BackendStatus status) { + final current = _current; + if (current == null || status.sourceId != current.sourceId) return; + switch (status.phase) { + case BackendPhase.buffering: + _setState(current.ready ? const Buffering() : const Loading()); + case BackendPhase.ready: + final firstReady = !current.ready; + if (!status.playing && firstReady && _playWhenReady) { + // 載入好了,play 還在路上。 + return; + } + current.ready = true; + _setState(status.playing ? const Playing() : const Paused()); + if (status.playing) _consecutiveSkips = 0; + if (firstReady) unawaited(_prepareLookAhead()); + case BackendPhase.idle || BackendPhase.ended: + return; + } + } + + void _onProgress(SourceProgress sourceProgress) { + final current = _current; + if (current == null || sourceProgress.sourceId != current.sourceId) return; + final progress = sourceProgress.progress; + current.progress = progress; + final now = _now(); + _lastProgressAt = now; + if (!current.audible && progress.position > Duration.zero) { + current.audible = true; + _logAudible(current, now); + } + if (!_progress.isClosed) _progress.add(progress); + } + + void _onEvent(BackendEvent event) { + switch (event) { + case SourceAdvanced(:final from, :final to, :final end): + _onAdvanced(from, to, end); + case SourceEnded(:final id, :final end): + final current = _current; + if (current == null || id != current.sourceId) return; + _onEnded(current, end); + case SourceFailed(:final id, :final failure, :final cause): + final current = _current; + if (current == null || id != current.sourceId) return; + _onFailed(current, failure, cause); + } + } + + void _onAdvanced(int from, int to, TrackEndReason end) { + final current = _current; + final lookAhead = _lookAhead; + if (current == null || + current.sourceId != from || + lookAhead == null || + lookAhead.sourceId != to) { + return; + } + final now = _now(); + final previous = current.progress; + final lastProgressAt = _lastProgressAt; + _log.info( + 'Look-ahead handover', + tag: _tag, + fields: { + 'from': '${current.stream.track}', + 'to': '${lookAhead.stream.track}', + 'end': end.name, + 'previousPositionMs': ?previous?.position.inMilliseconds, + 'previousDurationMs': ?previous?.duration?.inMilliseconds, + if (lastProgressAt != null) + 'sinceLastProgressMs': now.difference(lastProgressAt).inMilliseconds, + }, + ); + if (end == TrackEndReason.endedEarly) { + // M1 不回頭重試已經交接掉的那一首,只記下來。 + _log.warning( + 'The previous track ended early at the handover', + tag: _tag, + fields: {'track': '${current.stream.track}'}, + ); + } + _clearLookAhead(); + _queue.moveNext(); + _emitQueue(); + _retries = 0; + _candidateSwitched = false; + _handover = _Handover( + at: now, + previousEnd: _estimatedEnd(previous, lastProgressAt), + ); + _current = _Current( + generation: ++_generation, + sourceId: to, + stream: lookAhead.stream, + candidate: 0, + )..ready = true; + unawaited(_prepareLookAhead()); + } + + void _onEnded(_Current current, TrackEndReason end) { + switch (end) { + case TrackEndReason.completed: + final next = _queue.next; + if (next == null) { + _log.info('Queue finished', tag: _tag); + _current = null; + _clearLookAhead(); + _resumeAt = Duration.zero; + _setState(const Idle()); + return; + } + // 前瞻沒來得及接上(例如還在解析):照一般的下一首開始。 + final prepared = _takeLookAhead(next.index); + _queue.moveNext(); + _emitQueue(); + unawaited(_beginTrack(prepared: prepared)); + case TrackEndReason.endedEarly: + final error = NetworkError(pluginId: current.stream.track.sourceTypeId); + _log.report('Stream ended early', error, tag: _tag); + unawaited( + _recover(const StreamInterrupted(), error, _positionOf(current)), + ); + } + } + + void _onFailed(_Current current, BackendFailure failure, Object? cause) { + final pluginId = current.stream.track.sourceTypeId; + _log.warning( + 'Stream failed', + tag: _tag, + error: cause, + fields: { + 'track': '${current.stream.track}', + 'failure': failure.name, + 'candidate': current.candidate, + }, + ); + switch (failure) { + case BackendFailure.open: + // 開不起來、解不了:對使用者是「播不了」,不是網路問題。 + unawaited( + _recover( + const StreamUnopenable(), + Unsupported(pluginId: pluginId), + _resumeAt, + ), + ); + case BackendFailure.interrupted: + unawaited( + _recover( + const StreamInterrupted(), + NetworkError(pluginId: pluginId), + _positionOf(current), + ), + ); + } + } + + /// 從最後一次位置推算上一首播到結尾的時間。 + static DateTime? _estimatedEnd(PlaybackProgress? last, DateTime? at) { + if (last == null || at == null) return at; + final duration = last.duration; + if (duration == null || duration <= last.position) return at; + return at.add(duration - last.position); + } + + Duration _positionOf(_Current current) => + current.progress?.position ?? _resumeAt; + + // ---- 恢復 ----------------------------------------------------------------- + + Future _recover( + PlaybackFailure failure, + AppError error, + Duration position, + ) async { + final current = _current; + final action = decideRecovery( + failure, + retries: _retries, + candidateSwitched: _candidateSwitched, + hasOtherCandidate: + current != null && + current.candidate + 1 < current.stream.candidates.length, + consecutiveSkips: _consecutiveSkips, + queueLength: _queue.state.tracks.length, + ); + _log.info( + 'Playback recovery', + tag: _tag, + fields: { + 'track': '${_queue.state.current}', + 'error': error.typeName, + 'action': switch (action) { + RetryAfter() => 'retry', + TryNextCandidate() => 'nextCandidate', + SkipTrack() => 'skip', + StopPlayback() => 'stop', + }, + }, + ); + switch (action) { + case RetryAfter(:final delay, :final attempt): + _retries++; + final generation = ++_generation; + _current = null; + _clearLookAhead(); + _resumeAt = position; + _setState(Retrying(error: error, attempt: attempt, delay: delay)); + _retryTimer = Timer(delay, () { + _retryTimer = null; + if (generation == _generation) { + // 等待期間的 seek 記在 _resumeAt。 + unawaited(_load(position: _resumeAt)); + } + }); + case TryNextCandidate(): + _candidateSwitched = true; + _generation++; + await _open( + current!.stream, + candidate: current.candidate + 1, + position: position, + ); + case SkipTrack(): + _consecutiveSkips++; + final next = _queue.next; + if (next == null) return _stopWith(Failed(error)); + final prepared = _takeLookAhead(next.index); + _queue.moveNext(); + _emitQueue(); + await _beginTrack(prepared: prepared); + case StopPlayback(): + await _stopWith(Failed(error)); + } + } + + Future _stopWith(PlaybackState state) async { + _generation++; + _cancelRetry(); + _clearLookAhead(); + _current = null; + _setState(state); + await _backend.stop(); + } + + void _cancelRetry() { + _retryTimer?.cancel(); + _retryTimer = null; + } + + // ---- 輸出 ----------------------------------------------------------------- + + void _setState(PlaybackState state) { + // 沒有欄位的狀態是 const 單例;Retrying、Failed 每次都是新的。 + if (identical(state, _state)) return; + _state = state; + _log.debug( + 'Playback state', + tag: _tag, + fields: { + 'state': switch (state) { + Idle() => 'idle', + Loading() => 'loading', + Playing() => 'playing', + Paused() => 'paused', + Buffering() => 'buffering', + Retrying() => 'retrying', + Failed() => 'failed', + }, + }, + ); + if (!_states.isClosed) _states.add(state); + } + + void _emitQueue() { + if (!_queueStates.isClosed) _queueStates.add(_queue.state); + } + + /// 一首開始出聲時記一筆:接上前瞻的記「估計的間隔」(上一首推算的結束時間 + /// 到這首第一次回報位置),其他記從要求到出聲的時間。 + void _logAudible(_Current current, DateTime now) { + final handover = _handover; + final previousEnd = handover?.previousEnd; + final requestedAt = _requestedAt; + _log.info( + 'Track audible', + tag: _tag, + fields: { + 'track': '${current.stream.track}', + if (handover != null) ...{ + 'sinceHandoverMs': now.difference(handover.at).inMilliseconds, + if (previousEnd != null) + 'estimatedGapMs': now.difference(previousEnd).inMilliseconds, + } else if (requestedAt != null) + 'sinceRequestMs': now.difference(requestedAt).inMilliseconds, + }, + ); + _handover = null; + } +} + +final class _Current { + _Current({ + required this.generation, + required this.sourceId, + required this.stream, + required this.candidate, + }); + + final int generation; + final int sourceId; + final ResolvedStream stream; + + /// 開的是第幾個候選。 + final int candidate; + + /// 後端回報過載入好(之後的緩衝是 [Buffering] 而不是 [Loading])。 + bool ready = false; + + /// 回報過大於 0 的位置。 + bool audible = false; + PlaybackProgress? progress; +} + +final class _LookAhead { + _LookAhead({ + required this.queueIndex, + required this.stream, + required this.sourceId, + }); + + final int queueIndex; + final ResolvedStream stream; + final int sourceId; + Timer? refresh; +} + +final class _Handover { + _Handover({required this.at, required this.previousEnd}); + + final DateTime at; + + /// 從上一首最後的位置推算的結束時間;沒有位置時為 `null`。 + final DateTime? previousEnd; +} diff --git a/app/lib/playback/playback_providers.dart b/app/lib/playback/playback_providers.dart new file mode 100644 index 00000000..6f4223a7 --- /dev/null +++ b/app/lib/playback/playback_providers.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/core_providers.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/platform/platform_capabilities.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/audio_backends.dart'; +import 'package:fmp/playback/playback_controller.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/playback/queue_model.dart'; +import 'package:fmp/playback/stream_resolver.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; + +/// 平台宣告的播放能力;沒有時(未驗證的平台)讀它會拋 `Unsupported`。那些 +/// 平台在 `main()` 就只開「此平台尚未支援」,走不到這裡。 +final _playbackSupportProvider = Provider( + (ref) => + ref.watch(platformCapabilitiesProvider).playback ?? (throw Unsupported()), +); + +/// 整個 App 唯一的播放後端(Android 的音訊焦點見 `AudioBackend`)。 +final audioBackendProvider = Provider((ref) { + final backend = createAudioBackend( + ref.watch(_playbackSupportProvider).backend, + log: ref.watch(logProvider), + ); + ref.onDispose(() => unawaited(backend.dispose())); + return backend; +}); + +/// UI 唯一的播放入口(ADR 0018 §決定 1)。 +final playbackControllerProvider = Provider((ref) { + final controller = PlaybackController( + backend: ref.watch(audioBackendProvider), + resolver: StreamResolver( + plugin: (pluginId) => ref.read(pluginRegistryProvider).value?[pluginId], + formats: ref.watch(_playbackSupportProvider).formats, + ), + log: ref.watch(logProvider), + ); + ref.onDispose(() => unawaited(controller.dispose())); + return controller; +}); + +/// 播放狀態:先給目前的值,之後每次改變。 +final playbackStateProvider = StreamProvider((ref) async* { + final controller = ref.watch(playbackControllerProvider); + yield controller.state; + yield* controller.states; +}); + +/// 佇列:先給目前的值,之後每次改變。 +final playbackQueueProvider = StreamProvider((ref) async* { + final controller = ref.watch(playbackControllerProvider); + yield controller.queue; + yield* controller.queueStates; +}); diff --git a/app/lib/playback/playback_state.dart b/app/lib/playback/playback_state.dart new file mode 100644 index 00000000..e8948ec9 --- /dev/null +++ b/app/lib/playback/playback_state.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/core/errors/app_error.dart'; + +/// 播放狀態(ADR 0018 §決定 2):只有一份,只由 `PlaybackController` 寫。 +/// +/// 播的是哪一首看 `QueueState`,兩者沒有共同欄位;位置、時長、緩衝這類高頻 +/// 資料走 [PlaybackProgress] 的 stream,不在這裡。 +@immutable +sealed class PlaybackState { + const PlaybackState(); +} + +/// 沒有在播:還沒開始,或佇列播完了。 +final class Idle extends PlaybackState { + const Idle(); +} + +/// 正在解析串流或開流,還沒出聲。 +final class Loading extends PlaybackState { + const Loading(); +} + +final class Playing extends PlaybackState { + const Playing(); +} + +final class Paused extends PlaybackState { + const Paused(); +} + +/// 已經開始播,中途等資料。 +final class Buffering extends PlaybackState { + const Buffering(); +} + +/// 失敗後等著重試(ADR 0018 §決定 7):[attempt] 從 1 起算,[delay] 後重試。 +final class Retrying extends PlaybackState { + const Retrying({ + required this.error, + required this.attempt, + required this.delay, + }); + + final AppError error; + final int attempt; + final Duration delay; +} + +/// 停下來了:連續跳過到上限,或最後一首也播不了(ADR 0018 §決定 7)。 +final class Failed extends PlaybackState { + const Failed(this.error); + + final AppError error; +} + +/// 目前這首的位置、時長與已緩衝的位置。 +@immutable +final class PlaybackProgress { + const PlaybackProgress({ + required this.position, + this.duration, + this.buffered = Duration.zero, + }); + + final Duration position; + + /// 還不知道時為 `null`。 + final Duration? duration; + final Duration buffered; +} diff --git a/app/lib/playback/queue_model.dart b/app/lib/playback/queue_model.dart new file mode 100644 index 00000000..f09ac855 --- /dev/null +++ b/app/lib/playback/queue_model.dart @@ -0,0 +1,77 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/domain/track_key.dart'; + +/// 佇列的一份快照(ADR 0018 §決定 4)。與播放狀態沒有共同欄位:播到哪一首 +/// 只在這裡。 +@immutable +final class QueueState { + const QueueState({required this.tracks, required this.currentIndex}) + : assert( + currentIndex == null || + (currentIndex >= 0 && currentIndex < tracks.length), + ); + + static const empty = QueueState(tracks: [], currentIndex: null); + + /// 曲目,依播放順序。同一首可以出現兩次,以位置區分。 + final List tracks; + + /// 目前這首的位置;佇列是空的時為 `null`。 + final int? currentIndex; + + TrackKeyParts? get current => switch (currentIndex) { + final index? => tracks[index], + null => null, + }; + + bool get hasNext => currentIndex != null && currentIndex! + 1 < tracks.length; + + bool get hasPrevious => currentIndex != null && currentIndex! > 0; +} + +/// 佇列的真相(ADR 0018 §決定 4):M1 只在記憶體、只有依序播放的 `queue` +/// 模式、不持久化(M1 design 3.16)。其他模式、隨機、上限在 M2。 +/// +/// 只由 `PlaybackController` 呼叫。 +final class QueueModel { + QueueState _state = QueueState.empty; + + QueueState get state => _state; + + /// 以 [tracks] 取代佇列,從 [startIndex] 開始(播放某一首=清單加起點)。 + void replace(List tracks, {int startIndex = 0}) { + if (tracks.isEmpty) { + _state = QueueState.empty; + return; + } + RangeError.checkValidIndex(startIndex, tracks, 'startIndex'); + _state = QueueState( + tracks: List.unmodifiable(tracks), + currentIndex: startIndex, + ); + } + + /// 下一首的位置與曲目;已經是最後一首(或佇列是空的)時為 `null`。 + ({int index, TrackKeyParts track})? get next { + if (!_state.hasNext) return null; + final index = _state.currentIndex! + 1; + return (index: index, track: _state.tracks[index]); + } + + /// 往下一首;已經是最後一首就不動並回傳 `false`。 + bool moveNext() => _moveTo((_state.currentIndex ?? -1) + 1); + + /// 往上一首;已經是第一首就不動並回傳 `false`。 + bool movePrevious() => _moveTo((_state.currentIndex ?? 0) - 1); + + bool _moveTo(int index) { + if (_state.currentIndex == null || + index < 0 || + index >= _state.tracks.length) { + return false; + } + _state = QueueState(tracks: _state.tracks, currentIndex: index); + return true; + } +} diff --git a/app/lib/playback/recovery_policy.dart b/app/lib/playback/recovery_policy.dart new file mode 100644 index 00000000..ee1ad8a2 --- /dev/null +++ b/app/lib/playback/recovery_policy.dart @@ -0,0 +1,122 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'package:fmp/core/errors/app_error.dart'; + +// 播放失敗時怎麼辦(ADR 0018 §決定 7 的 M1 部分):純函數,狀態由 +// `PlaybackController` 保管與寫入。M1 沒有連線偵測(「不在 Online 時暫停 +// 計數」)、試聽片段的設定(一律跳過,等於設定的預設值)、緩衝飢餓與輸出裝置 +// 失敗的處理、正常播放 10 秒後重試計數歸零(M1 換歌才歸零),也沒有提示 UI +// (PR 12 接 Toaster)。 + +/// 一次播放失敗。 +@immutable +sealed class PlaybackFailure { + const PlaybackFailure(); +} + +/// 解析串流時插件或網路層丟出的錯誤。 +final class ResolveFailed extends PlaybackFailure { + const ResolveFailed(this.error); + + final AppError error; +} + +/// 後端開不起來或解碼失敗。 +final class StreamUnopenable extends PlaybackFailure { + const StreamUnopenable(); +} + +/// 已經在播之後中斷,或提前結束。 +final class StreamInterrupted extends PlaybackFailure { + const StreamInterrupted(); +} + +/// [decideRecovery] 的結論。 +@immutable +sealed class RecoveryAction { + const RecoveryAction(); +} + +/// 等 [delay] 後從目前位置重試(重新解析)。[attempt] 從 1 起算。 +final class RetryAfter extends RecoveryAction { + const RetryAfter({required this.delay, required this.attempt}); + + final Duration delay; + final int attempt; +} + +/// 改開下一個候選串流。 +final class TryNextCandidate extends RecoveryAction { + const TryNextCandidate(); +} + +/// 跳到下一首。 +final class SkipTrack extends RecoveryAction { + const SkipTrack(); +} + +/// 停下來(連續跳過到上限)。 +final class StopPlayback extends RecoveryAction { + const StopPlayback(); +} + +/// 重試的等待:1、3、9 秒,共 3 次。 +const retryDelays = [ + Duration(seconds: 1), + Duration(seconds: 3), + Duration(seconds: 9), +]; + +/// 連續跳過的上限:佇列長度與這個數字取小的。 +const maxConsecutiveSkips = 10; + +/// 依 [failure] 決定下一步。 +/// +/// - [retries]:這一首已經重試過幾次。 +/// - [candidateSwitched]:這一首已經換過一次候選(ADR 只換一次)。 +/// - [hasOtherCandidate]:還有沒開過的候選。 +/// - [consecutiveSkips]:前面已經連續跳過幾首(這一首不算)。 +/// - [queueLength]:佇列的長度。 +/// +/// 要跳過時,若這一次跳過會讓連續跳過的次數達到佇列長度(或 10 首),就改成 +/// [StopPlayback]:整個佇列都播不了時停下來,不一直繞。 +RecoveryAction decideRecovery( + PlaybackFailure failure, { + required int retries, + required bool candidateSwitched, + required bool hasOtherCandidate, + required int consecutiveSkips, + required int queueLength, +}) { + RecoveryAction skipOrStop() => + consecutiveSkips + 1 >= math.min(queueLength, maxConsecutiveSkips) + ? const StopPlayback() + : const SkipTrack(); + + RecoveryAction retryOrSkip() => retries < retryDelays.length + ? RetryAfter(delay: retryDelays[retries], attempt: retries + 1) + : skipOrStop(); + + return switch (failure) { + ResolveFailed(:final error) => switch (error) { + NetworkError() || RateLimited() => retryOrSkip(), + // 無法取得(含只有試聽)、找不到、需登入與驗證類、不支援:重試也不會 + // 好。解析失敗與預期外同樣跳過,整個音源都壞時由連續跳過的上限停下。 + Unavailable() || + NotFound() || + AuthRequired() || + CredentialInvalid() || + VerificationRequired() || + Unsupported() || + ParseError() || + UnexpectedError() => skipOrStop(), + }, + StreamUnopenable() => + !candidateSwitched && hasOtherCandidate + ? const TryNextCandidate() + : skipOrStop(), + StreamInterrupted() => retryOrSkip(), + }; +} diff --git a/app/lib/playback/stream_resolver.dart b/app/lib/playback/stream_resolver.dart new file mode 100644 index 00000000..cfcdc96f --- /dev/null +++ b/app/lib/playback/stream_resolver.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +/// 把曲目解析成串流候選(ADR 0018 §決定 6)。 +/// +/// M1 直接呼叫插件的 `resolveStream`:沒有本機下載檔,也沒有網址快取 +/// (ADR 0016,M1 design 3.6)。丟出的錯誤都是 `AppError`。 +final class StreamResolver { + StreamResolver({required this._plugin, required List formats}) + : _formats = List.unmodifiable([ + for (final format in formats) + StreamFormat(container: format.container, codec: format.codec), + ]); + + /// 以插件 id(曲目鍵的第一段)找插件;沒裝就是 `null`。 + final SourcePlugin? Function(String pluginId) _plugin; + + /// 平台可播的格式(`PlaybackSupport.formats`),原樣交給插件挑候選。 + final List _formats; + + /// 解析 [track] 的播放串流。音源沒裝是 `Unsupported`。 + Future resolve(TrackKeyParts track) async { + final plugin = + _plugin(track.sourceTypeId) ?? + (throw Unsupported(pluginId: track.sourceTypeId)); + final candidates = await plugin.resolveStream( + StreamRequest( + sourceId: track.sourceId, + cid: track.cid, + formats: _formats, + ), + ); + return ResolvedStream(track: track, candidates: candidates); + } +} + +/// 一首曲目解析出的候選,依優先序排好,至少一個。 +@immutable +final class ResolvedStream { + ResolvedStream({required this.track, required this.candidates}) + : assert(candidates.isNotEmpty); + + /// 網址剩下不到這麼久就當成過期:留時間給開流與前瞻的預備。 + static const expiryMargin = Duration(seconds: 30); + + final TrackKeyParts track; + final List candidates; + + /// 第一個候選(前瞻用它)的網址在 [now] 還能不能用;沒有期限就一直能用。 + bool isFreshAt(DateTime now) => switch (candidates.first.expiresAt) { + final expiresAt? => now.isBefore(expiresAt.subtract(expiryMargin)), + null => true, + }; + + /// 第一個候選該重新解析的時間;沒有期限就是 `null`。 + DateTime? get refreshAt => candidates.first.expiresAt?.subtract(expiryMargin); +} diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 7c7baa09..dd4235ca 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import audio_session import flutter_js +import just_audio func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) FlutterJsPlugin.register(with: registry.registrar(forPlugin: "FlutterJsPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) } diff --git a/app/packages/fmp_lints/test/rules/layer_imports_test.dart b/app/packages/fmp_lints/test/rules/layer_imports_test.dart index fcfd6aa1..63968489 100644 --- a/app/packages/fmp_lints/test/rules/layer_imports_test.dart +++ b/app/packages/fmp_lints/test/rules/layer_imports_test.dart @@ -54,6 +54,24 @@ class LayerImportsTest extends FmpRuleTest { "import [!'package:background_downloader/background_downloader.dart'!];\n", ); + // ADR 0018:兩個播放引擎只在後端實作目錄;控制器、平台層、UI 都不行。 + // media_kit 的 libs 套件(media_kit_libs_windows_audio)算在同一系列。 + Future test_playbackEnginesOutsideTheBackends() async { + for (final path in [ + 'lib/playback/playback_controller.dart', + 'lib/playback/backends_helpers.dart', + 'lib/platform/audio/audio_windows.dart', + 'lib/ui/player_bar/player_bar.dart', + ]) { + await assertLints( + path, + "import [!'package:just_audio/just_audio.dart'!];\n" + "import [!'package:media_kit/media_kit.dart'!];\n" + "import [!'package:media_kit_libs_windows_audio/media_kit_libs_windows_audio.dart'!];\n", + ); + } + } + Future test_legacyImportFromOutside() => assertLints( 'lib/data/database.dart', "import [!'package:test/legacy_import/reader.dart'!];\n", @@ -110,6 +128,21 @@ class LayerImportsTest extends FmpRuleTest { ); } + Future test_playbackEnginesInTheBackends() async { + await assertLints( + 'lib/playback/backends/media_kit_backend.dart', + "import 'package:media_kit/media_kit.dart';\n" + "import 'package:media_kit_libs_windows_audio/media_kit_libs_windows_audio.dart';\n" + "import 'package:just_audio/just_audio.dart';\n", + ); + // 名稱相似但不同系列(不是 `<鍵>_` 開頭)的套件不算。 + await assertLints( + 'lib/playback/playback_controller.dart', + "import 'package:media_kitchen/media_kitchen.dart';\n" + "import 'package:just_audiobook/just_audiobook.dart';\n", + ); + } + Future test_testsMayImportAnyPackage() => assertLints( 'test/data/database_test.dart', "import 'package:drift/drift.dart';\n" diff --git a/app/pubspec.lock b/app/pubspec.lock index c4533e47..66a6ae71 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" args: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" boolean_selector: dependency: transitive description: @@ -274,7 +290,7 @@ packages: source: hosted version: "2.35.0" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -349,6 +365,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -410,6 +431,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" integration_test: dependency: "direct dev" description: flutter @@ -463,6 +492,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -535,6 +588,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_windows_audio: + dependency: "direct main" + description: + name: media_kit_libs_windows_audio + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 + url: "https://pub.dev" + source: hosted + version: "1.0.9" meta: dependency: transitive description: @@ -663,6 +732,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" process: dependency: transitive description: @@ -711,6 +788,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" shelf: dependency: transitive description: @@ -844,6 +937,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" + url: "https://pub.dev" + source: hosted + version: "3.4.2" talker: dependency: "direct main" description: @@ -916,6 +1017,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" uuid: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a1e663ad..9bd01eb6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -33,9 +33,18 @@ dependencies: flutter_js: 0.8.7 # 狀態管理(ADR 0013)。不用 riverpod_generator:provider 少,手寫即可。 flutter_riverpod: ^3.4.3 + # 播放後端(ADR 0018 §決定 3):just_audio(ExoPlayer)給 Android,media_kit + # (libmpv)給 Windows;兩者只准在 lib/playback/backends/ import + # (fmp_layer_imports)。 + just_audio: ^0.10.6 # Flutter 3.47 起 Material 以獨立套件發佈,app/ 直接用它,不 import # package:flutter/material.dart(app/AGENTS.md § Material)。 material_ui: ^1.5.0 + media_kit: ^1.2.6 + # media_kit 的 README 建議的 media_kit_libs_audio 會連 Android 的 libmpv 一起 + # 打包(每個 ABI 多幾 MB,ADR 0018 否決的做法),所以只加 Windows 那一個。它 + # 停在 2023-09 的 libmpv,建置時從 GitHub 下載。 + media_kit_libs_windows_audio: ^1.0.9 path: ^1.9.1 path_provider: ^2.1.6 sqlite3: ^3.6.0 @@ -49,6 +58,8 @@ dev_dependencies: analyzer: any build_runner: ^2.16.1 drift_dev: ^2.35.0 + # 播放控制器的測試以假時間跑重試與網址期限(test/playback/)。 + fake_async: ^1.3.3 flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/app/test/platform/platform_test.dart b/app/test/platform/platform_test.dart index 0eefd8c0..f2dd56a3 100644 --- a/app/test/platform/platform_test.dart +++ b/app/test/platform/platform_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fmp/core/app_flavor.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory_android.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory_windows.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/platform/audio/audio_android.dart'; +import 'package:fmp/platform/audio/audio_windows.dart'; import 'package:fmp/platform/fonts/fonts.dart'; import 'package:fmp/platform/fonts/fonts_android.dart'; import 'package:fmp/platform/fonts/fonts_windows.dart'; @@ -10,7 +13,8 @@ import 'package:fmp/platform/platform.dart'; void main() { group('AppPlatform.assemble', () { - test('Android has a data directory and picks glyphs by locale', () { + test('Android has a data directory, picks glyphs by locale and plays ' + 'with just_audio', () { final platform = AppPlatform.assemble( TargetPlatform.android, AppFlavor.dev, @@ -20,9 +24,15 @@ void main() { expect(platform.dataDirectory, isA()); expect(platform.capabilities.singleInstance, isFalse); expect(platform.capabilities.fontFallback, same(androidFontFallback)); + expect(platform.capabilities.playback, same(androidPlaybackSupport)); + expect( + platform.capabilities.playback?.backend, + AudioBackendKind.justAudio, + ); }); - test('Windows has a data directory, a single instance and named fonts', () { + test('Windows has a data directory, a single instance, named fonts and ' + 'plays with media_kit', () { final platform = AppPlatform.assemble( TargetPlatform.windows, AppFlavor.dev, @@ -32,6 +42,11 @@ void main() { expect(platform.dataDirectory, isA()); expect(platform.capabilities.singleInstance, isTrue); expect(platform.capabilities.fontFallback, same(windowsFontFallback)); + expect(platform.capabilities.playback, same(windowsPlaybackSupport)); + expect( + platform.capabilities.playback?.backend, + AudioBackendKind.mediaKit, + ); }); for (final unverified in [ @@ -46,6 +61,7 @@ void main() { expect(platform.capabilities.dataDirectory, isFalse); expect(platform.dataDirectory, isNull); expect(platform.capabilities.singleInstance, isFalse); + expect(platform.capabilities.playback, isNull); for (final language in FontLanguage.values) { expect( platform.capabilities.fontFallback.familiesFor(language), @@ -55,4 +71,20 @@ void main() { }); } }); + + test( + 'both verified platforms can play the test plugin and the M1 sources', + () { + // 測試插件的 WAV,B 站的 DASH 音訊(fMP4/AAC):插件照這張表挑候選。 + for (final support in [androidPlaybackSupport, windowsPlaybackSupport]) { + expect( + support.formats, + containsAll(const [ + PlayableFormat('wav', 'pcm_s16le'), + PlayableFormat('mp4', 'aac'), + ]), + ); + } + }, + ); } diff --git a/app/test/playback/backends/audio_backend_contract.dart b/app/test/playback/backends/audio_backend_contract.dart new file mode 100644 index 00000000..abde12f5 --- /dev/null +++ b/app/test/playback/backends/audio_backend_contract.dart @@ -0,0 +1,354 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; + +// 後端契約(ADR 0018 §如何確認):同一份行為斷言,跑假後端(`flutter test`, +// fake_audio_backend_contract_test.dart)與平台的真後端(`integration_test/ +// audio_backend_contract_test.dart`:Android 是 just_audio、Windows 是 +// media_kit)。真後端要 platform channel 或 libmpv,在 `flutter test` 裡建不 +// 起來。規則本身(結束分類、前瞻的清單修改)另外在 backend_rules_test.dart。 + +/// 定義一個測試案例:`flutter test` 傳 `test`,整合測試傳包了 `testWidgets` +/// 的版本。 +typedef DefineCase = void Function(String description, Future Function()); + +/// 對 [create] 建出的後端跑契約。[track] 是長 [trackLength] 的音檔, +/// [missing] 是開不起來的網址;[slack] 是引擎開流與事件延遲的餘裕。 +void audioBackendContract({ + required DefineCase define, + required Future Function() create, + required Uri track, + required Uri missing, + required Duration trackLength, + Duration slack = const Duration(seconds: 5), +}) { + var nextId = 0; + BackendSource source(Uri url) => BackendSource(id: ++nextId, url: url); + + final recorders = []; + Future record() async { + final recorder = Recorder(await create()); + recorders.add(recorder); + return recorder; + } + + // 失敗的案例也放掉引擎:留著還在播的播放器會拖累下一個案例。 + void defineCase(String description, Future Function() body) => + define(description, () async { + try { + await body(); + } finally { + for (final recorder in recorders) { + await recorder.close(); + } + recorders.clear(); + } + }); + + defineCase('plays a source to the end and reports it completed', () async { + final recorder = await record(); + final a = source(track); + await recorder.backend.open(a); + + await recorder.until( + () => recorder.events.whereType().isNotEmpty, + trackLength + slack, + ); + expect(recorder.events, [ + isA() + .having((e) => e.id, 'id', a.id) + .having((e) => e.end, 'end', TrackEndReason.completed), + ]); + expect( + recorder.statuses, + contains( + isA() + .having((s) => s.sourceId, 'sourceId', a.id) + .having((s) => s.playing, 'playing', isTrue) + .having((s) => s.phase, 'phase', BackendPhase.ready), + ), + ); + expect( + recorder.progress.where( + (p) => p.sourceId == a.id && p.progress.position > Duration.zero, + ), + isNotEmpty, + ); + await recorder.close(); + }); + + defineCase('hands over to the look-ahead', () async { + final recorder = await record(); + final a = source(track); + final b = source(track); + await recorder.backend.open(a); + await recorder.untilReady(a.id, slack); + await recorder.backend.setNext(b); + + await recorder.until( + () => recorder.events.whereType().isNotEmpty, + trackLength * 2 + slack, + ); + expect(recorder.events, [ + isA() + .having((e) => e.from, 'from', a.id) + .having((e) => e.to, 'to', b.id) + .having((e) => e.end, 'end', TrackEndReason.completed), + isA() + .having((e) => e.id, 'id', b.id) + .having((e) => e.end, 'end', TrackEndReason.completed), + ]); + expect( + recorder.statuses, + contains( + isA() + .having((s) => s.sourceId, 'sourceId', b.id) + .having((s) => s.playing, 'playing', isTrue), + ), + ); + await recorder.close(); + }); + + defineCase('a replaced look-ahead is the one handed over', () async { + final recorder = await record(); + final a = source(track); + final b = source(track); + final c = source(track); + await recorder.backend.open(a); + await recorder.untilReady(a.id, slack); + await recorder.backend.setNext(b); + await recorder.backend.setNext(c); + + await recorder.until( + () => recorder.events.whereType().isNotEmpty, + trackLength + slack, + ); + expect( + recorder.events.first, + isA() + .having((e) => e.from, 'from', a.id) + .having((e) => e.to, 'to', c.id), + ); + await recorder.close(); + }); + + defineCase('a cleared look-ahead is not played', () async { + final recorder = await record(); + final a = source(track); + await recorder.backend.open(a); + await recorder.untilReady(a.id, slack); + await recorder.backend.setNext(source(track)); + await recorder.backend.setNext(null); + + await recorder.until( + () => recorder.events.whereType().isNotEmpty, + trackLength + slack, + ); + expect(recorder.events, [ + isA().having((e) => e.id, 'id', a.id), + ]); + await recorder.close(); + }); + + defineCase('a source that cannot be opened fails as open', () async { + final recorder = await record(); + final bad = source(missing); + await recorder.backend.open(bad); + + await recorder.until(() => recorder.events.isNotEmpty, slack); + expect(recorder.events, [ + isA() + .having((e) => e.id, 'id', bad.id) + .having((e) => e.failure, 'failure', BackendFailure.open), + ]); + await recorder.close(); + }); + + // 換來源時引擎可能還在送上一個檔案的位置、結束與錯誤(media_kit 的事件不帶 + // 項目):它們不能算到新的來源上。 + Future playPastHalf(BackendSource a) async { + final recorder = await record(); + await recorder.backend.open(a); + await recorder.until( + () => recorder.progress.any( + (p) => p.sourceId == a.id && p.progress.position >= trackLength ~/ 2, + ), + trackLength + slack, + ); + return recorder; + } + + defineCase( + 'a source opened over a playing one starts at its own position', + () async { + final a = source(track); + final b = source(track); + final recorder = await playPastHalf(a); + await recorder.backend.open(b); + + await recorder.until( + () => recorder.progress.any((p) => p.sourceId == b.id), + slack, + ); + expect( + recorder.progress + .firstWhere((p) => p.sourceId == b.id) + .progress + .position, + lessThan(trackLength ~/ 2), + ); + await recorder.close(); + }, + ); + + defineCase( + 'a source opened over a playing one reports its own failure', + () async { + final a = source(track); + final bad = source(missing); + final recorder = await playPastHalf(a); + await recorder.backend.open(bad); + + await recorder.until(() => recorder.events.isNotEmpty, slack); + expect(recorder.events, [ + isA() + .having((e) => e.id, 'id', bad.id) + .having((e) => e.failure, 'failure', BackendFailure.open), + ]); + expect(recorder.progress.where((p) => p.sourceId == bad.id), isEmpty); + await recorder.close(); + }, + ); + + defineCase('pausing before the source is ready keeps it paused', () async { + final recorder = await record(); + final a = source(track); + final opened = recorder.backend.open(a); + await recorder.backend.pause(); + await opened; + await recorder.untilReady(a.id, slack); + + // 播著的話這段時間會播到一半以上。 + await Future.delayed(trackLength ~/ 2); + expect(recorder.statuses.last.sourceId, a.id); + expect(recorder.statuses.last.playing, isFalse); + expect( + recorder.progress.where( + (p) => p.sourceId == a.id && p.progress.position >= trackLength ~/ 4, + ), + isEmpty, + ); + expect(recorder.events, isEmpty); + await recorder.close(); + }); + + defineCase('starts at the given position', () async { + final recorder = await record(); + final a = source(track); + final start = trackLength ~/ 2; + await recorder.backend.open(a, start: start); + + await recorder.until( + () => recorder.progress.any((p) => p.sourceId == a.id), + slack, + ); + expect( + recorder.progress.firstWhere((p) => p.sourceId == a.id).progress.position, + greaterThanOrEqualTo(start - const Duration(milliseconds: 100)), + ); + await recorder.close(); + }); + + defineCase('pause and play toggle the playing flag', () async { + final recorder = await record(); + final a = source(track); + await recorder.backend.open(a); + await recorder.untilReady(a.id, slack); + + await recorder.backend.pause(); + await recorder.until( + () => + recorder.statuses.lastOrNull?.playing == false && + recorder.statuses.last.sourceId == a.id, + slack, + ); + expect(recorder.events, isEmpty); + + await recorder.backend.play(); + await recorder.until( + () => recorder.statuses.lastOrNull?.playing ?? false, + slack, + ); + await recorder.close(); + }); +} + +/// 收下後端發出的一切。 +final class Recorder { + Recorder(this.backend) { + _subscriptions + ..add(backend.status.listen((s) => _record(statuses, s))) + ..add(backend.progress.listen((p) => _record(progress, p))) + ..add(backend.events.listen((e) => _record(events, e))); + } + + final AudioBackend backend; + final statuses = []; + final progress = []; + final events = []; + final _subscriptions = >[]; + final _waiters = <(bool Function(), Completer)>[]; + + void _record(List list, T value) { + list.add(value); + for (final waiter in [..._waiters]) { + final (condition, done) = waiter; + if (condition()) { + _waiters.remove(waiter); + done.complete(); + } + } + } + + /// 等到 [condition] 成立(每收到一個值檢查一次);超過 [timeout] 就失敗並列出 + /// 收到的東西。真後端要實際播放,所以等的是實際時間。 + Future until(bool Function() condition, Duration timeout) { + if (condition()) return Future.value(); + final done = Completer(); + final waiter = (condition, done); + _waiters.add(waiter); + final timer = Timer(timeout, () { + if (done.isCompleted) return; + _waiters.remove(waiter); + done.completeError( + TestFailure( + 'Timed out after $timeout.\n' + 'events: $events\n' + 'statuses: $statuses', + ), + ); + }); + return done.future.whenComplete(timer.cancel); + } + + /// 等到 [sourceId] 載入好(ready)。 + Future untilReady(int sourceId, Duration timeout) => until( + () => statuses.any( + (s) => s.sourceId == sourceId && s.phase == BackendPhase.ready, + ), + timeout, + ); + + var _closed = false; + + Future close() async { + if (_closed) return; + _closed = true; + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await backend.dispose(); + } +} diff --git a/app/test/playback/backends/backend_rules_test.dart b/app/test/playback/backends/backend_rules_test.dart new file mode 100644 index 00000000..0b743863 --- /dev/null +++ b/app/test/playback/backends/backend_rules_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; + +void main() { + group('classifyTrackEnd', () { + const duration = Duration(seconds: 200); + + test('the end within the tolerance is completed', () { + expect( + classifyTrackEnd(position: duration, duration: duration), + TrackEndReason.completed, + ); + expect( + classifyTrackEnd( + position: duration - completionTolerance, + duration: duration, + ), + TrackEndReason.completed, + ); + }); + + test('stopping before the tolerance is an early end', () { + expect( + classifyTrackEnd( + position: + duration - completionTolerance - const Duration(milliseconds: 1), + duration: duration, + ), + TrackEndReason.endedEarly, + ); + expect( + classifyTrackEnd( + position: const Duration(seconds: 30), + duration: duration, + ), + TrackEndReason.endedEarly, + ); + }); + + test('a duration never reported is an early end', () { + expect( + classifyTrackEnd(position: Duration.zero, duration: null), + TrackEndReason.endedEarly, + ); + expect( + classifyTrackEnd(position: Duration.zero, duration: Duration.zero), + TrackEndReason.endedEarly, + ); + }); + }); + + group('LookAheadEdit', () { + test('appends to a playlist holding only the current source', () { + final edit = LookAheadEdit.of( + itemCount: 1, + currentIndex: 0, + append: true, + ); + expect(edit.removeIndices, isEmpty); + expect(edit.append, isTrue); + }); + + test('replaces an existing look-ahead', () { + final edit = LookAheadEdit.of( + itemCount: 2, + currentIndex: 0, + append: true, + ); + expect(edit.removeIndices, [1]); + expect(edit.append, isTrue); + }); + + test('after a handover trims the played source, highest index first', () { + final edit = LookAheadEdit.of( + itemCount: 3, + currentIndex: 1, + append: false, + ); + expect(edit.removeIndices, [2, 0]); + expect(edit.append, isFalse); + }); + + test('applying it leaves the current source first and one look-ahead', () { + // 照順序套用到一份清單上,結果是 [目前, 新前瞻]。 + for (final (count, current) in [(1, 0), (2, 0), (2, 1), (4, 2)]) { + final playlist = [for (var i = 0; i < count; i++) 'item$i']; + final edit = LookAheadEdit.of( + itemCount: count, + currentIndex: current, + append: true, + ); + for (final index in edit.removeIndices) { + playlist.removeAt(index); + } + if (edit.append) playlist.add('next'); + expect(playlist, ['item$current', 'next'], reason: '$count/$current'); + } + }); + + test('does nothing without a current source', () { + for (final (count, current) in [(0, 0), (2, -1), (2, 2)]) { + final edit = LookAheadEdit.of( + itemCount: count, + currentIndex: current, + append: true, + ); + expect(edit.removeIndices, isEmpty); + expect(edit.append, isFalse); + } + }); + }); + + group('BackendSource', () { + test('keeps only the media headers (ADR 0012)', () { + final source = BackendSource( + id: 1, + url: Uri.parse('https://cdn.example/a.m4a'), + headers: { + 'Referer': 'https://www.example.test/', + 'User-Agent': 'FMP', + 'Cookie': 'SESSDATA=FAKE_SESSDATA_123', + 'Authorization': 'Bearer FAKE_TOKEN', + 'X-Custom-Token': 'FAKE', + }, + ); + expect(source.headers, { + 'Referer': 'https://www.example.test/', + 'User-Agent': 'FMP', + }); + expect(() => source.headers['Cookie'] = 'x', throwsUnsupportedError); + }); + }); +} diff --git a/app/test/playback/backends/fake_audio_backend_contract_test.dart b/app/test/playback/backends/fake_audio_backend_contract_test.dart new file mode 100644 index 00000000..5933ea43 --- /dev/null +++ b/app/test/playback/backends/fake_audio_backend_contract_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../fake_audio_backend.dart'; +import 'audio_backend_contract.dart'; + +// 假後端跑同一份契約:控制器的測試靠它代表真後端,所以它的行為要和真後端 +// 對得上(真後端的那一份在 integration_test/audio_backend_contract_test.dart)。 +void main() { + final missing = Uri.parse('asset:///missing.wav'); + group('FakeAudioBackend', () { + audioBackendContract( + define: (description, body) => test(description, body), + create: () async => FakeAudioBackend( + durationOf: (_) => const Duration(milliseconds: 400), + failsToOpen: (url) => url == missing, + tick: const Duration(milliseconds: 20), + ), + track: Uri.parse('asset:///tone.wav'), + missing: missing, + trackLength: const Duration(milliseconds: 400), + slack: const Duration(seconds: 2), + ); + }); +} diff --git a/app/test/playback/dev_playback_entry_test.dart b/app/test/playback/dev_playback_entry_test.dart new file mode 100644 index 00000000..0edb8488 --- /dev/null +++ b/app/test/playback/dev_playback_entry_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/dev_playback_entry.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:yaml/yaml.dart'; + +import '../plugins/plugin_harness.dart'; + +void main() { + group('devPlaybackRequest', () { + test('the bare flag plays the bundled test plugin', () { + expect( + devPlaybackRequest(AppFlavor.dev, ['--other', '--fmp-dev-playback']), + isEmpty, + ); + }); + + test('each value is a track key, in order', () { + expect( + devPlaybackRequest(AppFlavor.dev, [ + '--fmp-dev-playback=bilibili:BV1a', + '--fmp-dev-plugin=/data/local/tmp/bilibili.js', + '--fmp-dev-playback=bilibili:BV1b:7', + ]), + ['bilibili:BV1a', 'bilibili:BV1b:7'], + ); + }); + + test('is absent without the flag', () { + expect( + devPlaybackRequest(AppFlavor.dev, ['--fmp-dev-playbackx']), + isNull, + ); + }); + + test('prod reads nothing', () { + expect( + devPlaybackRequest(AppFlavor.prod, [ + '--fmp-dev-playback', + '--fmp-dev-playback=bilibili:BV1a', + ]), + isNull, + ); + }); + }); + + group('parseDevPlaybackTracks', () { + test('parses two- and three-part keys', () { + expect( + parseDevPlaybackTracks(['bilibili:BV1a', ' bilibili:BV1b:7']), + const [ + TrackKeyParts(sourceTypeId: 'bilibili', sourceId: 'BV1a'), + TrackKeyParts(sourceTypeId: 'bilibili', sourceId: 'BV1b', cid: 7), + ], + ); + }); + + test('rejects a malformed key', () { + expect( + () => parseDevPlaybackTracks(['bilibili:BV1a', 'nope']), + throwsFormatException, + ); + }); + }); + + test('the test tracks resolve in the bundled test plugin', () async { + expect(devTestPluginAsset, testPluginFile.path); + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + for (final id in devTestTracks) { + final candidates = await plugin.resolveStream( + StreamRequest( + sourceId: id, + formats: [StreamFormat(container: 'wav', codec: 'pcm_s16le')], + ), + ); + expect(candidates.first.url.scheme, 'asset'); + } + }); + + test('the test plugin is bundled only in the dev flavor', () { + final assets = + (loadYaml(File('pubspec.yaml').readAsStringSync()) + as YamlMap)['flutter']['assets'] + as YamlList; + expect( + assets, + contains( + allOf( + containsPair('path', 'test/fixtures/plugins/test_plugin/'), + containsPair('flavors', ['dev']), + ), + ), + ); + expect( + devTestPluginAsset, + startsWith('test/fixtures/plugins/test_plugin/'), + ); + }); +} diff --git a/app/test/playback/fake_audio_backend.dart b/app/test/playback/fake_audio_backend.dart new file mode 100644 index 00000000..0c3fb78c --- /dev/null +++ b/app/test/playback/fake_audio_backend.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:fmp/playback/backends/audio_backend.dart'; +import 'package:fmp/playback/backends/backend_rules.dart'; +import 'package:fmp/playback/playback_state.dart'; + +/// 不出聲的 [AudioBackend]:位置以計時器前進,所以在 `fakeAsync` 裡也照樣跑。 +/// +/// 清單的修改與結束的分類用和真後端同一份規則(`backend_rules.dart`), +/// `audio_backend_contract.dart` 以同一份斷言跑它與真後端。 +final class FakeAudioBackend implements AudioBackend { + FakeAudioBackend({ + this.durationOf = _twoSeconds, + this.failsToOpen = _never, + this.tick = const Duration(milliseconds: 50), + }); + + static Duration _twoSeconds(Uri url) => const Duration(seconds: 2); + static bool _never(Uri url) => false; + + /// 每個網址的長度。 + final Duration Function(Uri url) durationOf; + + /// 開不起來的網址。 + final bool Function(Uri url) failsToOpen; + + /// 位置每次前進多少(也是位置回報的間隔)。 + final Duration tick; + + /// 每次 [open] 的來源與起點,依序。 + final opened = []; + final openedAt = []; + + /// 每次 [setNext] 的參數,依序。 + final nextSources = []; + + final _status = StreamController.broadcast(); + final _progress = StreamController.broadcast(); + final _events = StreamController.broadcast(); + + final _playlist = []; + int _index = -1; + Duration _position = Duration.zero; + bool _playing = false; + bool _loaded = false; + Timer? _ticker; + + BackendSource? get current => + _index >= 0 && _index < _playlist.length ? _playlist[_index] : null; + + /// 目前清單裡的來源(目前+前瞻)。 + List get playlist => List.unmodifiable(_playlist); + + bool get playing => _playing; + + @override + Stream get status => _status.stream; + + @override + Stream get progress => _progress.stream; + + @override + Stream get events => _events.stream; + + @override + Future open( + BackendSource source, { + Duration start = Duration.zero, + bool play = true, + }) async { + opened.add(source); + openedAt.add(start); + _ticker?.cancel(); + _playlist + ..clear() + ..add(source); + _index = 0; + _position = start; + _playing = play; + _loaded = false; + _emitStatus(BackendPhase.buffering); + if (failsToOpen(source.url)) { + _playing = false; + _events.add( + SourceFailed( + id: source.id, + failure: BackendFailure.open, + cause: 'unopenable', + ), + ); + return; + } + _loaded = true; + _emitStatus(BackendPhase.ready); + _emitProgress(); + _schedule(); + } + + @override + Future setNext(BackendSource? next) async { + nextSources.add(next); + if (current == null) return; + final edit = LookAheadEdit.of( + itemCount: _playlist.length, + currentIndex: _index, + append: next != null, + ); + _apply(edit, next); + } + + @override + Future play() async { + if (current == null) return; + _playing = true; + _emitStatus(BackendPhase.ready); + _schedule(); + } + + @override + Future pause() async { + _playing = false; + _ticker?.cancel(); + if (current != null) _emitStatus(BackendPhase.ready); + } + + @override + Future seek(Duration position) async { + _position = position; + _emitProgress(); + } + + @override + Future stop() async { + _ticker?.cancel(); + _playlist.clear(); + _index = -1; + _playing = false; + _emitStatus(BackendPhase.idle); + } + + @override + Future dispose() async { + _ticker?.cancel(); + await _status.close(); + await _progress.close(); + await _events.close(); + } + + /// 目前的來源在這裡中斷(像 ExoPlayer 的連線錯誤),引擎的錯誤是 [cause]。 + void interrupt({Object cause = 'interrupted'}) { + final source = current; + if (source == null) return; + _ticker?.cancel(); + _playing = false; + _events.add( + SourceFailed( + id: source.id, + failure: BackendFailure.interrupted, + cause: cause, + ), + ); + } + + void _apply(LookAheadEdit edit, BackendSource? next) { + for (final index in edit.removeIndices) { + _playlist.removeAt(index); + if (index < _index) _index--; + } + if (edit.append && next != null) _playlist.add(next); + } + + void _schedule() { + _ticker?.cancel(); + if (!_playing || current == null) return; + _ticker = Timer.periodic(tick, (_) => _advanceTime()); + } + + void _advanceTime() { + final source = current; + if (source == null) return; + final duration = durationOf(source.url); + _position += tick; + if (_position < duration) { + _emitProgress(); + return; + } + final end = classifyTrackEnd(position: duration, duration: duration); + if (_index + 1 < _playlist.length) { + final next = _playlist[_index + 1]; + _index++; + _position = Duration.zero; + _events.add(SourceAdvanced(from: source.id, to: next.id, end: end)); + // 真後端一樣:接上後修剪播完的那一個。 + _apply( + LookAheadEdit.of( + itemCount: _playlist.length, + currentIndex: _index, + append: false, + ), + null, + ); + _emitStatus(BackendPhase.ready); + return; + } + _ticker?.cancel(); + _playing = false; + _events.add(SourceEnded(id: source.id, end: end)); + _emitStatus(BackendPhase.ended); + } + + void _emitStatus(BackendPhase phase) => _status.add( + BackendStatus(sourceId: current?.id, playing: _playing, phase: phase), + ); + + void _emitProgress() { + final source = current; + if (source == null || !_loaded) return; + _progress.add( + SourceProgress( + sourceId: source.id, + progress: PlaybackProgress( + position: _position, + duration: durationOf(source.url), + ), + ), + ); + } +} diff --git a/app/test/playback/fake_source_plugin.dart b/app/test/playback/fake_source_plugin.dart new file mode 100644 index 00000000..5c886fd4 --- /dev/null +++ b/app/test/playback/fake_source_plugin.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; + +/// 只有 `resolveStream` 的插件,回傳由 [respond] 決定;記下每次請求。 +final class FakeSourcePlugin implements SourcePlugin { + FakeSourcePlugin(this.respond, {String id = 'fmp-test'}) + : manifest = PluginManifest( + id: id, + name: 'Fake', + version: '1.0.0', + author: 'FMP tests', + capabilities: const {PluginCapability.resolveStream}, + allowedHosts: const ['cdn.example'], + ); + + FutureOr> Function(StreamRequest request) respond; + + final requests = []; + + /// 對 [sourceId] 解析了幾次。 + int resolvedCount(String sourceId) => + requests.where((r) => r.sourceId == sourceId).length; + + @override + final PluginManifest manifest; + + @override + PluginHealth get health => PluginHealth.ready; + + @override + Future get whenUnresponsive => Completer().future; + + @override + Future search(SearchQuery query) => + throw UnimplementedError('search'); + + @override + Future> resolveStream(StreamRequest request) async { + requests.add(request); + return respond(request); + } + + @override + void close() {} +} + +/// `https://cdn.example/` 的候選。 +StreamCandidate candidate( + String name, { + Map headers = const {}, + DateTime? expiresAt, +}) => StreamCandidate( + url: Uri.parse('https://cdn.example/$name'), + headers: headers, + container: 'mp4', + codec: 'aac', + expiresAt: expiresAt, +); diff --git a/app/test/playback/playback_controller_test.dart b/app/test/playback/playback_controller_test.dart new file mode 100644 index 00000000..5e2f9e70 --- /dev/null +++ b/app/test/playback/playback_controller_test.dart @@ -0,0 +1,645 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/core/logging/log.dart'; +import 'package:fmp/core/logging/log_file.dart'; +import 'package:fmp/core/logging/log_record.dart'; +import 'package:fmp/core/redaction/redactor.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/playback/playback_controller.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/playback/stream_resolver.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:path/path.dart' as p; + +import '../support/pump_until.dart'; +import 'fake_audio_backend.dart'; +import 'fake_source_plugin.dart'; + +TrackKeyParts track(String id) => + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: id); + +final _start = DateTime.utc(2026, 9, 30, 12); + +/// 控制器加假後端、假插件與 log,全部在 [async] 的假時間裡。 +final class Harness { + Harness( + this.async, { + FutureOr> Function(StreamRequest request)? respond, + Duration trackLength = const Duration(seconds: 2), + bool Function(Uri url)? failsToOpen, + }) : plugin = FakeSourcePlugin( + respond ?? (request) => [candidate('${request.sourceId}.m4a')], + ), + backend = FakeAudioBackend( + durationOf: (_) => trackLength, + failsToOpen: failsToOpen ?? (_) => false, + ) { + controller = PlaybackController( + backend: backend, + resolver: StreamResolver( + plugin: (id) => id == plugin.manifest.id ? plugin : null, + formats: const [PlayableFormat('mp4', 'aac')], + ), + log: log, + now: now, + ); + controller.states.listen(states.add); + } + + final FakeAsync async; + final FakeSourcePlugin plugin; + final FakeAudioBackend backend; + final log = Log(redactor: Redactor(), minimumLevel: LogLevel.debug); + late final PlaybackController controller; + final states = []; + + DateTime now() => _start.add(async.elapsed); + + void elapse(Duration duration) => async.elapse(duration); + + /// 讓已排定的非同步工作跑完(不前進時間)。 + void settle() => async.flushMicrotasks(); + + List get openedPaths => [ + for (final source in backend.opened) source.url.path, + ]; + + List logged(String message) => [ + for (final record in log.history) + if (record.message == message) record, + ]; +} + +void main() { + group('playing a queue', () { + test('hands over to the look-ahead, which is resolved only once', () { + fakeAsync((async) { + final h = Harness(async); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.controller.state, isA()); + expect(h.plugin.resolvedCount('b'), 1); + expect(h.backend.nextSources.last?.url.path, '/b.m4a'); + + h.elapse(const Duration(seconds: 2)); + expect(h.controller.queue.currentIndex, 1); + expect(h.controller.state, isA()); + // b 由前瞻接上,後端只 open 過 a。 + expect(h.openedPaths, ['/a.m4a']); + expect(h.logged('Look-ahead handover'), hasLength(1)); + expect(h.logged('Look-ahead handover').single.fields, { + 'from': 'fmp-test:a', + 'to': 'fmp-test:b', + 'end': 'completed', + 'previousPositionMs': greaterThanOrEqualTo(1900), + 'previousDurationMs': 2000, + 'sinceLastProgressMs': lessThanOrEqualTo(100), + }); + + h.elapse(const Duration(seconds: 3)); + expect(h.controller.state, isA()); + expect(h.plugin.resolvedCount('a'), 1); + expect(h.plugin.resolvedCount('b'), 1); + expect(h.logged('Track audible'), hasLength(2)); + expect( + h.logged('Track audible').last.fields, + containsPair('sinceHandoverMs', isA()), + ); + }); + }); + + test('pausing and resuming does not resolve the look-ahead again', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + for (var i = 0; i < 3; i++) { + unawaited(h.controller.pause()); + h.elapse(const Duration(milliseconds: 100)); + unawaited(h.controller.play()); + h.elapse(const Duration(milliseconds: 100)); + } + + expect(h.controller.state, isA()); + expect(h.plugin.resolvedCount('b'), 1); + expect(h.backend.nextSources, hasLength(1)); + }); + }); + + test('next uses the prepared look-ahead without resolving again', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + unawaited(h.controller.next()); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.controller.queue.currentIndex, 1); + expect(h.openedPaths, ['/a.m4a', '/b.m4a']); + expect(h.plugin.resolvedCount('b'), 1); + expect(h.controller.state, isA()); + }); + }); + + test('previous at the first track restarts it; next at the last does ' + 'nothing', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(seconds: 5)); + + unawaited(h.controller.previous()); + h.settle(); + expect(h.controller.queue.currentIndex, 0); + expect(h.openedPaths, ['/a.m4a']); + + unawaited(h.controller.next()); + h.elapse(const Duration(milliseconds: 100)); + unawaited(h.controller.next()); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 1); + expect(h.openedPaths, ['/a.m4a', '/b.m4a']); + + unawaited(h.controller.previous()); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 0); + expect(h.openedPaths, ['/a.m4a', '/b.m4a', '/a.m4a']); + }); + }); + + test('pause, play and seek go to the backend', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a')])); + h.elapse(const Duration(milliseconds: 100)); + + unawaited(h.controller.pause()); + h.settle(); + expect(h.controller.state, isA()); + expect(h.backend.playing, isFalse); + + unawaited(h.controller.play()); + h.settle(); + expect(h.controller.state, isA()); + + final positions = []; + h.controller.progress.listen((p) => positions.add(p.position)); + unawaited(h.controller.seek(const Duration(seconds: 30))); + h.settle(); + expect(positions.last, const Duration(seconds: 30)); + }); + }); + + test('pausing while loading stays paused once loaded', () { + fakeAsync((async) { + final gate = Completer(); + final h = Harness( + async, + respond: (request) async { + await gate.future; + return [candidate('${request.sourceId}.m4a')]; + }, + ); + unawaited(h.controller.playQueue([track('a')])); + h.settle(); + expect(h.controller.state, isA()); + + unawaited(h.controller.pause()); + gate.complete(); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.state, isA()); + expect(h.backend.playing, isFalse); + }); + }); + + test('a seek while resolving is where the track starts', () { + fakeAsync((async) { + final gate = Completer(); + final h = Harness( + async, + trackLength: const Duration(seconds: 60), + respond: (request) async { + await gate.future; + return [candidate('${request.sourceId}.m4a')]; + }, + ); + unawaited(h.controller.playQueue([track('a')])); + h.settle(); + unawaited(h.controller.seek(const Duration(seconds: 20))); + gate.complete(); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.backend.openedAt, [const Duration(seconds: 20)]); + }); + }); + + test('disposing while resolving opens nothing and sets no look-ahead', () { + fakeAsync((async) { + final gates = {'a': Completer(), 'b': Completer()}; + final h = Harness( + async, + trackLength: const Duration(seconds: 60), + respond: (request) async { + await gates[request.sourceId]!.future; + return [candidate('${request.sourceId}.m4a')]; + }, + ); + // a 載入好、b 的前瞻還在解析時 dispose。 + unawaited(h.controller.playQueue([track('a'), track('b')])); + gates['a']!.complete(); + h.elapse(const Duration(milliseconds: 100)); + expect(h.plugin.resolvedCount('b'), 1); + unawaited(h.controller.dispose()); + gates['b']!.complete(); + h.elapse(const Duration(milliseconds: 100)); + expect(h.backend.nextSources, isEmpty); + + // 解析中 dispose:解析回來後不開流。 + final gate = Completer(); + final h2 = Harness( + async, + respond: (request) async { + await gate.future; + return [candidate('${request.sourceId}.m4a')]; + }, + ); + unawaited(h2.controller.playQueue([track('a')])); + h2.settle(); + unawaited(h2.controller.dispose()); + gate.complete(); + h2.elapse(const Duration(milliseconds: 100)); + expect(h2.backend.opened, isEmpty); + }); + }); + + test('a superseded resolution is dropped', () { + fakeAsync((async) { + final slow = Completer>(); + final h = Harness( + async, + respond: (request) => request.sourceId == 'a' + ? slow.future + : [candidate('${request.sourceId}.m4a')], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.settle(); + unawaited(h.controller.next()); + h.elapse(const Duration(milliseconds: 100)); + slow.complete([candidate('a.m4a')]); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.openedPaths, ['/b.m4a']); + expect(h.controller.queue.currentIndex, 1); + }); + }); + + test('the backend only gets media headers', () { + fakeAsync((async) { + final h = Harness( + async, + respond: (request) => [ + candidate( + '${request.sourceId}.m4a', + headers: { + 'Referer': 'https://www.example.test/', + 'Cookie': 'SESSDATA=FAKE_SESSDATA_123', + }, + ), + ], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.backend.opened.single.headers, { + 'Referer': 'https://www.example.test/', + }); + expect(h.backend.nextSources.last?.headers, { + 'Referer': 'https://www.example.test/', + }); + }); + }); + }); + + group('expiry', () { + test('an expiring look-ahead is resolved again before the handover', () { + fakeAsync((async) { + late Harness h; + h = Harness( + async, + trackLength: const Duration(seconds: 120), + respond: (request) => [ + candidate( + '${request.sourceId}-${h.plugin.requests.length}.m4a', + expiresAt: h.now().add(const Duration(seconds: 60)), + ), + ], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + expect(h.plugin.resolvedCount('b'), 1); + final first = h.backend.nextSources.last; + + // 60 秒的期限、30 秒的餘裕:30 秒時換掉前瞻。 + h.elapse(const Duration(seconds: 30)); + expect(h.plugin.resolvedCount('b'), 2); + final refreshed = h.backend.nextSources.last; + expect(refreshed?.id, isNot(first?.id)); + expect(h.logged('Look-ahead refreshed before expiry'), hasLength(1)); + + h.elapse(const Duration(seconds: 90)); + expect(h.controller.queue.currentIndex, 1); + expect(h.openedPaths, hasLength(1)); + }); + }); + + test('next resolves again when the prepared look-ahead is stale', () { + fakeAsync((async) { + late Harness h; + h = Harness( + async, + trackLength: const Duration(seconds: 120), + respond: (request) => [ + candidate( + '${request.sourceId}.m4a', + // 解析出來就在餘裕內:不排重新解析,到用的時候才檢查。 + expiresAt: h.now().add(const Duration(seconds: 20)), + ), + ], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + expect(h.plugin.resolvedCount('b'), 1); + + unawaited(h.controller.next()); + h.elapse(const Duration(milliseconds: 100)); + expect(h.plugin.resolvedCount('b'), 2); + expect(h.logged('Look-ahead expired; resolving again'), hasLength(1)); + expect(h.openedPaths, ['/a.m4a', '/b.m4a']); + }); + }); + }); + + group('recovery', () { + test( + 'a stream that cannot be opened switches to the next candidate once', + () { + fakeAsync((async) { + final h = Harness( + async, + respond: (request) => [ + candidate('${request.sourceId}-1.m4a'), + candidate('${request.sourceId}-2.m4a'), + candidate('${request.sourceId}-3.m4a'), + ], + failsToOpen: (url) => !url.path.startsWith('/a-2'), + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.openedPaths, ['/a-1.m4a', '/a-2.m4a']); + expect(h.controller.state, isA()); + expect(h.plugin.resolvedCount('a'), 1); + }); + }, + ); + + test('after the second candidate fails too the track is skipped', () { + fakeAsync((async) { + final h = Harness( + async, + respond: (request) => [ + candidate('${request.sourceId}-1.m4a'), + candidate('${request.sourceId}-2.m4a'), + candidate('${request.sourceId}-3.m4a'), + ], + failsToOpen: (url) => url.path.startsWith('/a-'), + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.openedPaths, ['/a-1.m4a', '/a-2.m4a', '/b-1.m4a']); + expect(h.controller.queue.currentIndex, 1); + expect(h.controller.state, isA()); + }); + }); + + test('NotFound skips to the next track at once', () { + fakeAsync((async) { + final h = Harness( + async, + respond: (request) => request.sourceId == 'a' + ? throw NotFound(pluginId: 'fmp-test') + : [candidate('${request.sourceId}.m4a')], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect(h.controller.queue.currentIndex, 1); + expect(h.openedPaths, ['/b.m4a']); + expect(h.states.whereType(), isEmpty); + }); + }); + + test('network errors retry after 1, 3 and 9 seconds, then skip', () { + fakeAsync((async) { + final h = Harness( + async, + respond: (request) => request.sourceId == 'a' + ? throw NetworkError(pluginId: 'fmp-test') + : [candidate('${request.sourceId}.m4a')], + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.settle(); + expect( + h.controller.state, + isA() + .having((s) => s.attempt, 'attempt', 1) + .having((s) => s.delay, 'delay', const Duration(seconds: 1)) + .having((s) => s.error, 'error', isA()), + ); + + h.elapse(const Duration(seconds: 1)); + expect(h.plugin.resolvedCount('a'), 2); + expect( + h.controller.state, + isA().having((s) => s.attempt, 'attempt', 2), + ); + + h.elapse(const Duration(seconds: 3)); + expect(h.plugin.resolvedCount('a'), 3); + expect( + h.controller.state, + isA().having((s) => s.attempt, 'attempt', 3), + ); + + h.elapse(const Duration(seconds: 9)); + expect(h.plugin.resolvedCount('a'), 4); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 1); + expect(h.controller.state, isA()); + }); + }); + + test('an interrupted stream retries from its position', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a')])); + h.elapse(const Duration(seconds: 5)); + + h.backend.interrupt(); + h.settle(); + expect(h.controller.state, isA()); + + h.elapse(const Duration(seconds: 1)); + expect(h.plugin.resolvedCount('a'), 2); + expect( + h.backend.openedAt.last, + greaterThanOrEqualTo(const Duration(seconds: 4)), + ); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.state, isA()); + }); + }); + + test('a seek while waiting to retry is where the retry starts', () { + fakeAsync((async) { + final h = Harness(async, trackLength: const Duration(seconds: 60)); + unawaited(h.controller.playQueue([track('a')])); + h.elapse(const Duration(seconds: 5)); + + h.backend.interrupt(); + h.settle(); + expect(h.controller.state, isA()); + unawaited(h.controller.seek(const Duration(seconds: 30))); + h.elapse(const Duration(seconds: 1)); + + expect(h.backend.openedAt.last, const Duration(seconds: 30)); + }); + }); + + test('stops once the whole queue has been skipped', () { + fakeAsync((async) { + final h = Harness( + async, + respond: (_) => throw NotFound(pluginId: 'fmp-test'), + ); + unawaited(h.controller.playQueue([track('a'), track('b')])); + h.elapse(const Duration(milliseconds: 100)); + + expect( + h.controller.state, + isA().having((s) => s.error, 'error', isA()), + ); + expect(h.plugin.requests, hasLength(2)); + expect(h.backend.current, isNull); + + // 再按播放從目前這首(b)重新開始,連續跳過的計數歸零。 + unawaited(h.controller.play()); + h.elapse(const Duration(milliseconds: 100)); + expect(h.plugin.requests, hasLength(3)); + }); + }); + + test('a source not installed is skipped like any unplayable track', () { + fakeAsync((async) { + final h = Harness(async); + unawaited( + h.controller.playQueue([ + const TrackKeyParts(sourceTypeId: 'missing', sourceId: 'x'), + track('b'), + ]), + ); + h.elapse(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 1); + expect(h.controller.state, isA()); + }); + }); + }); + + group('engine messages in the log', () { + // 引擎的錯誤可能帶完整的簽名網址(YouTube.js 探針看到 mpv 的 + // `ffmpeg: Opening '…/videoplayback?…'`)。後端以 SourceFailed.cause 交出, + // 控制器只以 error 交給 log 門面:記憶體歷史與 log 檔都要是遮過的。 + const secrets = [ + 'FAKE_SIG_VALUE_123', + 'FAKE_LSIG_VALUE_456', + 'FAKE_N_VALUE_789', + '203.0.113.9', + 'FAKE_UPSIG_VALUE', + 'FAKE_E_VALUE', + '1790000001', + ]; + const signedUrl = + 'https://rr3---sn-fake.googlevideo.com/videoplayback?expire=1790000000' + '&ei=FAKE_EI&ip=203.0.113.9&id=o-FAKE&itag=251' + '&sig=FAKE_SIG_VALUE_123&lsig=FAKE_LSIG_VALUE_456&n=FAKE_N_VALUE_789'; + const bilibiliUrl = + 'https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/x.m4s' + '?e=FAKE_E_VALUE&deadline=1790000001&upsig=FAKE_UPSIG_VALUE'; + + for (final (name, cause, host) in <(String, Object, String)>[ + ('an mpv log line', "ffmpeg: Opening '$signedUrl'", 'googlevideo.com'), + ( + 'an exception', + HttpException('Source error', uri: Uri.parse(signedUrl)), + 'googlevideo.com', + ), + ( + 'a Bilibili mpv log line', + 'ffmpeg: tcp: Connection to $bilibiliUrl failed', + 'bilivideo.com', + ), + ]) { + test( + 'a signed URL in $name is redacted in the history and the file', + () async { + final temp = await Directory.systemTemp.createTemp( + 'fmp_playback_log', + ); + addTearDown(() => temp.delete(recursive: true)); + final log = Log( + redactor: Redactor(), + minimumLevel: LogLevel.debug, + file: LogFile(Directory(p.join(temp.path, logDirectoryName))), + ); + final plugin = FakeSourcePlugin((_) => [candidate('a.m4a')]); + final backend = FakeAudioBackend( + durationOf: (_) => const Duration(minutes: 5), + ); + final controller = PlaybackController( + backend: backend, + resolver: StreamResolver( + plugin: (_) => plugin, + formats: const [PlayableFormat('mp4', 'aac')], + ), + log: log, + ); + addTearDown(controller.dispose); + addTearDown(backend.dispose); + + await controller.playQueue([track('a')]); + await pumpUntil(() => controller.state is Playing); + backend.interrupt(cause: cause); + await pumpUntil(() => controller.state is Retrying); + await log.file!.flush(); + + final history = log.history.map((r) => r.toJsonLine()).join('\n'); + final file = await log.file!.currentFile.readAsString(); + expect(log.history.map((r) => r.message), contains('Stream failed')); + expect(file, contains(host)); + for (final secret in secrets) { + expect(history, isNot(contains(secret)), reason: 'history'); + expect(file, isNot(contains(secret)), reason: 'file'); + } + }, + ); + } + }); +} diff --git a/app/test/playback/queue_model_test.dart b/app/test/playback/queue_model_test.dart new file mode 100644 index 00000000..e9a427d4 --- /dev/null +++ b/app/test/playback/queue_model_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/queue_model.dart'; + +TrackKeyParts track(String id) => + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: id); + +void main() { + test('an empty queue has no current track and cannot move', () { + final queue = QueueModel(); + expect(queue.state.current, isNull); + expect(queue.next, isNull); + expect(queue.moveNext(), isFalse); + expect(queue.movePrevious(), isFalse); + }); + + test('plays in order from the start index', () { + final queue = QueueModel() + ..replace([track('a'), track('b'), track('c')], startIndex: 1); + + expect(queue.state.current, track('b')); + expect(queue.next, (index: 2, track: track('c'))); + expect(queue.state.hasPrevious, isTrue); + expect(queue.moveNext(), isTrue); + expect(queue.state.currentIndex, 2); + }); + + test('next stops at the last track', () { + final queue = QueueModel()..replace([track('a'), track('b')]); + expect(queue.moveNext(), isTrue); + + expect(queue.state.hasNext, isFalse); + expect(queue.next, isNull); + expect(queue.moveNext(), isFalse); + expect(queue.state.current, track('b')); + }); + + test('previous stops at the first track', () { + final queue = QueueModel()..replace([track('a'), track('b')]); + + expect(queue.state.hasPrevious, isFalse); + expect(queue.movePrevious(), isFalse); + expect(queue.state.current, track('a')); + queue.moveNext(); + expect(queue.movePrevious(), isTrue); + expect(queue.state.current, track('a')); + }); + + test('the same track can appear twice, told apart by position', () { + final queue = QueueModel()..replace([track('a'), track('a')]); + expect(queue.next, (index: 1, track: track('a'))); + }); + + test('replacing with an empty list empties the queue', () { + final queue = QueueModel() + ..replace([track('a')]) + ..replace([]); + expect(queue.state.currentIndex, isNull); + }); + + test('a start index outside the list is rejected', () { + expect( + () => QueueModel().replace([track('a')], startIndex: 1), + throwsRangeError, + ); + }); + + test('the snapshot does not change when the caller edits its list', () { + final tracks = [track('a')]; + final queue = QueueModel()..replace(tracks); + tracks.add(track('b')); + expect(queue.state.tracks, [track('a')]); + }); +} diff --git a/app/test/playback/recovery_policy_test.dart b/app/test/playback/recovery_policy_test.dart new file mode 100644 index 00000000..d41b46e3 --- /dev/null +++ b/app/test/playback/recovery_policy_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/playback/recovery_policy.dart'; + +RecoveryAction decide( + PlaybackFailure failure, { + int retries = 0, + bool candidateSwitched = false, + bool hasOtherCandidate = false, + int consecutiveSkips = 0, + int queueLength = 20, +}) => decideRecovery( + failure, + retries: retries, + candidateSwitched: candidateSwitched, + hasOtherCandidate: hasOtherCandidate, + consecutiveSkips: consecutiveSkips, + queueLength: queueLength, +); + +Matcher retryAfter(int seconds, int attempt) => isA() + .having((a) => a.delay, 'delay', Duration(seconds: seconds)) + .having((a) => a.attempt, 'attempt', attempt); + +void main() { + group('network and rate limit errors', () { + for (final error in [NetworkError(), RateLimited()]) { + test( + '${error.typeName} retries after 1, 3 and 9 seconds, then skips', + () { + final failure = ResolveFailed(error); + expect(decide(failure), retryAfter(1, 1)); + expect(decide(failure, retries: 1), retryAfter(3, 2)); + expect(decide(failure, retries: 2), retryAfter(9, 3)); + expect(decide(failure, retries: 3), isA()); + }, + ); + } + + test('an interrupted stream retries like a network error', () { + const failure = StreamInterrupted(); + expect(decide(failure), retryAfter(1, 1)); + expect(decide(failure, retries: 3), isA()); + }); + }); + + group('errors that retrying does not fix are skipped at once', () { + for (final error in [ + Unavailable(reason: UnavailableReason.copyright), + Unavailable(reason: UnavailableReason.previewOnly), + NotFound(), + AuthRequired(), + CredentialInvalid(), + VerificationRequired(), + Unsupported(), + ParseError(), + UnexpectedError(), + ]) { + test(error.toString(), () { + expect(decide(ResolveFailed(error)), isA()); + }); + } + }); + + group('a stream that cannot be opened', () { + test('tries the next candidate once', () { + expect( + decide(const StreamUnopenable(), hasOtherCandidate: true), + isA(), + ); + expect( + decide( + const StreamUnopenable(), + hasOtherCandidate: true, + candidateSwitched: true, + ), + isA(), + ); + }); + + test('skips without another candidate', () { + expect(decide(const StreamUnopenable()), isA()); + }); + }); + + group('consecutive skips', () { + test('stop when this skip reaches the queue length', () { + final failure = ResolveFailed(NotFound()); + expect( + decide(failure, consecutiveSkips: 1, queueLength: 3), + isA(), + ); + expect( + decide(failure, consecutiveSkips: 2, queueLength: 3), + isA(), + ); + expect(decide(failure, queueLength: 1), isA()); + }); + + test('stop at ten in a longer queue', () { + final failure = ResolveFailed(NotFound()); + expect( + decide(failure, consecutiveSkips: 8, queueLength: 100), + isA(), + ); + expect( + decide(failure, consecutiveSkips: 9, queueLength: 100), + isA(), + ); + }); + + test('also apply after the retries run out', () { + expect( + decide( + ResolveFailed(NetworkError()), + retries: 3, + consecutiveSkips: 1, + queueLength: 2, + ), + isA(), + ); + }); + }); +} diff --git a/app/test/playback/stream_resolver_test.dart b/app/test/playback/stream_resolver_test.dart new file mode 100644 index 00000000..e703ec49 --- /dev/null +++ b/app/test/playback/stream_resolver_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/playback/stream_resolver.dart'; +import 'package:fmp/plugins/source_dto.dart'; + +import 'fake_source_plugin.dart'; + +void main() { + const formats = [ + PlayableFormat('mp4', 'aac'), + PlayableFormat('webm', 'opus'), + ]; + + test('asks the plugin for playback with the platform formats', () async { + final plugin = FakeSourcePlugin((_) => [candidate('a.m4a')]); + final resolver = StreamResolver( + plugin: (id) => id == 'fmp-test' ? plugin : null, + formats: formats, + ); + + final stream = await resolver.resolve( + const TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'BV1', cid: 7), + ); + + expect(stream.candidates.single.url.path, '/a.m4a'); + final request = plugin.requests.single; + expect(request.toJson(), { + 'sourceId': 'BV1', + 'cid': 7, + 'purpose': 'playback', + 'formats': [ + {'container': 'mp4', 'codec': 'aac'}, + {'container': 'webm', 'codec': 'opus'}, + ], + }); + expect(request.purpose, StreamPurpose.playback); + }); + + test('a source without an installed plugin is Unsupported', () async { + final resolver = StreamResolver(plugin: (_) => null, formats: formats); + await expectLater( + resolver.resolve( + const TrackKeyParts(sourceTypeId: 'missing', sourceId: 'x'), + ), + throwsA( + isA().having((e) => e.pluginId, 'pluginId', 'missing'), + ), + ); + }); + + test('plugin errors pass through unchanged', () async { + final error = NotFound(pluginId: 'fmp-test'); + final plugin = FakeSourcePlugin((_) => throw error); + final resolver = StreamResolver(plugin: (_) => plugin, formats: formats); + await expectLater( + resolver.resolve( + const TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'x'), + ), + throwsA(same(error)), + ); + }); + + group('ResolvedStream freshness', () { + final now = DateTime.utc(2026, 9, 30, 12); + ResolvedStream expiring(DateTime? expiresAt) => ResolvedStream( + track: const TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'x'), + candidates: [candidate('a', expiresAt: expiresAt)], + ); + + test('without an expiry it is always fresh', () { + expect(expiring(null).isFreshAt(now), isTrue); + expect(expiring(null).refreshAt, isNull); + }); + + test('is stale within the margin before expiry', () { + final margin = ResolvedStream.expiryMargin; + final stream = expiring(now.add(margin + const Duration(seconds: 1))); + expect(stream.isFreshAt(now), isTrue); + expect(stream.isFreshAt(now.add(const Duration(seconds: 1))), isFalse); + expect(stream.refreshAt, now.add(const Duration(seconds: 1))); + }); + }); +} diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 474a97c7..8a1a712b 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterJsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterJsPlugin")); + MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 460d8f0a..8a160dfb 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_js + media_kit_libs_windows_audio ) list(APPEND FLUTTER_FFI_PLUGIN_LIST