diff --git a/.claude/skills/verify-on-device/SKILL.md b/.claude/skills/verify-on-device/SKILL.md index a065e6be5..4900bc407 100644 --- a/.claude/skills/verify-on-device/SKILL.md +++ b/.claude/skills/verify-on-device/SKILL.md @@ -20,14 +20,15 @@ description: >- ## 模式與平台 -- **預設是重播**:dev flavor 加內附測試插件(`--fmp-dev-playback`,三首 2 秒的本機音檔、 - 不連網)。App 還沒有每插件的重播開關(ADR 0015 §決定 5 只做到契約測試),目前重播就是 - 測試插件。 +- **預設是重播**:dev flavor 加內附測試插件 `fmp-test`(以 `--fmp-dev-plugin` 安裝,搜尋任何 + 關鍵字都有三首 2 秒的本機音檔、不連網;步驟見 `references/runtime-state.md` § 開發入口)。 + App 還沒有每插件的重播開關(ADR 0015 §決定 5 只做到契約測試),目前重播就是測試插件。 - **真實連線只在**:改動本身是插件、網路層、登入,或正在錄 fixture。只做最少的操作(搜尋 一次、播一首),不批次、不迴圈。上游改版交給 Debug 頁的健康檢查,不靠每個 PR 的實機驗證。 - **平台**:Android 模擬器與 Windows 每個使用者看得到的 PR 都驗。Linux、macOS、iOS 在各自的 平台任務加進 `references/`,現在不驗。 -- UI 還沒有入口的功能,用開發入口(`references/runtime-state.md` § 開發入口)。 +- 操作一律走 UI(搜尋頁點一首就開始播);UI 還沒有入口的功能(安裝插件)用開發入口 + (`references/runtime-state.md` § 開發入口)。 ## 前置 @@ -64,7 +65,8 @@ terminal 從 repo 根目錄開始)。 | 播放焦點、媒體工作階段 | `dumpsys audio`(Android)、`scripts/smtc_probe.ps1`(Windows,M2 起才有東西) | 優先讀文字(語意樹、log),畫面問題(版面、溢出、主題)才截圖。**截圖與貼進回報的內容不得含 -個人資訊**:Windows 的身分頁會印出資料目錄的完整路徑,含使用者名稱;這種截圖不要貼出,只回報文字觀察。 +個人資訊**:log 的 `App started` 一行帶資料目錄的完整路徑(含使用者名稱),引用時改成 +`<資料目錄>`;畫面上若出現使用者名稱(例如檔案路徑),那張截圖不要貼出,只回報文字觀察。 只截 App 視窗,不截整個桌面。截圖一律存到 session 暫存目錄,不進 repo。 ## 3. 操作 diff --git a/.claude/skills/verify-on-device/references/android.md b/.claude/skills/verify-on-device/references/android.md index d7564a4d2..bb99f62db 100644 --- a/.claude/skills/verify-on-device/references/android.md +++ b/.claude/skills/verify-on-device/references/android.md @@ -34,26 +34,24 @@ adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity ### 帶開發入口的參數 -dev 入口(`--fmp-dev-plugin=`、`--fmp-dev-playback`,見 `runtime-state.md`)以 intent extra -`dart_entrypoint_args` 傳進來。`--esal` 的值是**以逗號分隔的陣列**,所以多個參數用逗號接起來, -參數值本身不能含逗號: +dev 入口(`--fmp-dev-plugin=`,見 `runtime-state.md`)以 intent extra `dart_entrypoint_args` 傳進來。 +`--esal` 的值是**以逗號分隔的陣列**,所以多個參數用逗號接起來,參數值本身不能含逗號。 -```bash -MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity \ - --esal dart_entrypoint_args --fmp-dev-playback -``` - -用 `--fmp-dev-plugin=` 時,App 要讀得到插件檔:先推到 `/data/local/tmp`(App 讀不到),再以 -`run-as` 複製進 App 的私有目錄: +App 要讀得到插件檔:先推到 `/data/local/tmp`(App 讀不到),再以 `run-as` 複製進 App 的私有目錄。 +測試插件(重播)是 `app/test/fixtures/plugins/test_plugin/test_plugin.js`,B 站插件(真實)是 +`fmp-plugins/bilibili/bilibili.js`: ```bash MSYS_NO_PATHCONV=1 adb push <插件.js 的本機路徑> /data/local/tmp/x.js MSYS_NO_PATHCONV=1 adb shell "run-as com.personal.fmp.dev sh -c 'mkdir -p files && cp /data/local/tmp/x.js files/x.js'" MSYS_NO_PATHCONV=1 adb shell rm /data/local/tmp/x.js MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity \ - --esal dart_entrypoint_args --fmp-dev-plugin=/data/data/com.personal.fmp.dev/files/x.js,--fmp-dev-playback=bilibili: + --esal dart_entrypoint_args --fmp-dev-plugin=/data/data/com.personal.fmp.dev/files/x.js ``` +裝好的插件存在資料庫,之後不帶參數啟動也在(搜尋頁的音源 chip)。兩個插件都要時各裝一次, +檔名分開(例如 `test.js`、`bilibili.js`)。 + 用 `adb shell mkdir` 建的目錄屬於 `shell`,App 寫不進去(`errno = 13`);一律用 `run-as`。 要重新帶參數,先 `adb shell am force-stop com.personal.fmp.dev`(只停 dev)。 @@ -62,7 +60,7 @@ MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.M - **語意樹**:`PYTHONIOENCODING=utf-8 python .claude/skills/verify-on-device/scripts/ax_flatten.py --limit 30`。 預設經 `orca emulator ax`;沒有 Orca 時加 `--adb`,改讀 `adb exec-out uiautomator dump /dev/tty`(直接輸出到 stdout,裝置上不留檔)。Flutter 的語意經 - uiautomator 變成節點:多數文字在 `content-desc`,少數(例如身分頁的資料目錄)在 `text`, + uiautomator 變成節點:多數文字在 `content-desc`,少數(例如輸入框的內容)在 `text`, 腳本兩個都讀。`norm=` 餵 `orca emulator tap `,`center=` 餵 `adb shell input tap `。 - **log**:`adb logcat -s flutter`(debug build 的 console log)。結構化的 log 在 App 的 `files/logs/fmp.jsonl`:`adb shell run-as com.personal.fmp.dev cat files/logs/fmp.jsonl`。 @@ -87,3 +85,6 @@ MSYS_NO_PATHCONV=1 adb shell am start -n com.personal.fmp.dev/com.personal.fmp.M - 模擬器卡死(畫面不動、`ax` 是 0 個節點)時,以 `-no-snapshot-load` 重開。 - 模擬器的 DNS 不快取,第一次連線多約 1 秒;測錯誤路徑關網路: `adb shell svc wifi disable && adb shell svc data disable`(驗完開回來)。 +- `flutter test integration_test/... -d emulator-5554` 跑完會**解除安裝** `com.personal.fmp.dev`(資料與複製進 + `files/` 的插件一起消失)。之後要重新 `adb install -r` dev APK、重新複製插件。 +- 播放中畫面一直在更新時 `uiautomator dump` 會回 `could not get idle state`:改用 `screencap` 看版面,或先暫停。 diff --git a/.claude/skills/verify-on-device/references/runtime-state.md b/.claude/skills/verify-on-device/references/runtime-state.md index e78720b9e..c50a8cf0d 100644 --- a/.claude/skills/verify-on-device/references/runtime-state.md +++ b/.claude/skills/verify-on-device/references/runtime-state.md @@ -22,8 +22,10 @@ dev 解析到舊版正式資料的位置(Windows 的 `Documents\FMP`、`%APPDA - `logs/fmp.jsonl`(與輪替的 `fmp.1.jsonl`、`fmp.2.jsonl`):JSON Lines,單檔 2MB,已經過遮蔽。 每行 `{"time","level","tag","message","fields"}`,例如 `{"level":"info","tag":"app","message":"App started","fields":{"flavor":"dev","buildMode":"debug"}}`。 - 驗證時用 `tag` 與 `message` 找(`Installed a plugin from the development entry`、 - `Development playback started`、`Look-ahead handover`、`Track audible`)。 + 驗證時用 `tag` 與 `message` 找(`App started` 的 `flavor`、`dataDirectory`, + `Installed a plugin from the development entry`、`Track requested`、`Look-ahead handover`、 + `Track audible`、`Search failed`、`Playback stopped`)。`dataDirectory` 含使用者名稱,引用時改成 + `<資料目錄>`。 ## 讀狀態 @@ -51,16 +53,19 @@ Dart VM Service(`flutter run` 印的 URI)可讀活的物件;URI 是本機 ## 開發入口(只在 dev flavor;prod 一律忽略) -原始碼:`lib/plugins/install/dev_plugin_entry.dart`、`lib/playback/dev_playback_entry.dart`。 +原始碼:`lib/plugins/install/dev_plugin_entry.dart`。播放一律走 UI:搜尋頁選音源、搜尋、點一首, +就從那一首開始依序播整份結果。 | 入口 | 作用 | |---|---| -| `--fmp-dev-plugin=<路徑>` 或環境變數 `FMP_DEV_PLUGIN` | 啟動時安裝該路徑的插件安裝檔(`.js`);兩者都有時參數優先 | -| `--fmp-dev-playback` | 安裝內附的測試插件(`fmp-test`),依序播它的三首(`tone-220`、`tone-440`、`tone-880`,每首是同一個 2 秒的本機 wav,不連網) | -| `--fmp-dev-playback=<曲目鍵>` | 播指定曲目(例如 `bilibili:`);重複參數播多首。插件要已安裝,或同時帶 `--fmp-dev-plugin`。要連網,屬於「真實」模式 | +| `--fmp-dev-plugin=<路徑>` 或環境變數 `FMP_DEV_PLUGIN` | 啟動時安裝該路徑的插件安裝檔(`.js`);兩者都有時參數優先。已安裝同 id 的視為更新 | -- 參數不用逗號分隔(Android 的 `--esal` 會切陣列,見 `android.md`)。 +| 插件 | 安裝檔 | 模式 | +|---|---|---| +| 測試插件 `fmp-test`(音源名稱 `FMP Test Plugin`) | `app/test/fixtures/plugins/test_plugin/test_plugin.js` | 重播:搜尋任何關鍵字都回三首(第一頁兩首、「載入更多」第三首),串流是 dev 版內附的 2 秒 wav,不連網;關鍵字剛好是 `fail` 時搜尋以限流失敗,用來看錯誤提示 | +| B 站 `bilibili` | `fmp-plugins/bilibili/bilibili.js`(與 FMP 同層的 clone) | 真實:搜尋與解析都連網,照 SKILL.md 只做最少的操作 | + +- Windows 直接給絕對路徑;Android 要先複製進 App 的私有目錄(`android.md`)。 - Windows 的環境變數只對該行程有效:PowerShell 用 `$env:FMP_DEV_PLUGIN='<路徑>'; Start-Process ...; Remove-Item Env:FMP_DEV_PLUGIN`。 -- 這兩個入口在 PR 12 的 UI 取代之前是驗證播放與插件的入口;之後以原始碼為準。 -- 身分頁的 `Dev playback: <狀態> <第幾首>/<總數>` 是播放入口的狀態。 +- 裝好的插件留在 `installed_plugins`,之後不帶參數啟動也會載入。 diff --git a/.claude/skills/verify-on-device/references/windows.md b/.claude/skills/verify-on-device/references/windows.md index 166f70f68..8da6520c8 100644 --- a/.claude/skills/verify-on-device/references/windows.md +++ b/.claude/skills/verify-on-device/references/windows.md @@ -12,7 +12,7 @@ flutter build windows --flavor dev --debug 只有 dev 的視窗標題是 `FMP Dev`)。直接執行,參數照 `runtime-state.md` 的開發入口: ```powershell -Start-Process build\windows\x64\dev\runner\Debug\fmp.exe -ArgumentList '--fmp-dev-playback' +Start-Process build\windows\x64\dev\runner\Debug\fmp.exe -ArgumentList '--fmp-dev-plugin=\test\fixtures\plugins\test_plugin\test_plugin.js' ``` - **dev 是單一實例**(mutex `Local\FMP_MainInstance-dev`):別的 worktree 開著的 FMP Dev 也算。 @@ -33,12 +33,12 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/msaa_tree.ps1 [-Filte powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/msaa_tree.ps1 -Click '<名稱>' [-Role 'push button'] [-Index 0] ``` -- 第一行 `nodes=`。身分頁約 11 個;之後頁面變多卻只剩個位數,代表無障礙橋卡住 +- 第一行 `nodes=`。先在同一個畫面記一次當基準(例如搜尋頁、有結果、播放列在);之後同一個 + 畫面只剩個位數,或送出提示後節點數掉到個位數、不再變動,代表無障礙橋卡住 (`docs/troubleshooting.md` 的 `Failed to update ui::AXTree`)。 - 輸出的矩形是螢幕上的物理像素。`-Click` 是**真的滑鼠點擊**,先用 `-Filter` 確認名稱;它會把游標 停回視窗左上角(游標下有 tooltip 會讓樹卡住)。 -- 身分頁上資料目錄那一行的語意名稱目前是空字串(畫面上看得到);要確認資料目錄就看 - `userdata-dev\` 是否存在,或截圖。 +- 要確認資料目錄,看 `userdata-dev\` 是否存在,或 log 的 `App started` 的 `dataDirectory`。 - 腳本只找行程 `fmp` 而且視窗標題是 `FMP Dev` 的(`-Proc`、`-Title` 改),不會點到舊版或 prod; exit 1 是沒有視窗,2 是 `-Click` 沒對到,3 是視窗拉不到前景。 @@ -46,7 +46,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/msaa_tree.ps1 -Click ```bash powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 \ - -Exe [-ArgLine '--fmp-dev-playback'] -Out [-Wait 8] [-KeepRunning] + -Exe [-ArgLine '--fmp-dev-plugin=<插件.js>'] -Out [-Wait 8] [-KeepRunning] powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 -Attach -Out ``` @@ -55,8 +55,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 -Atta 截圖、不結束它。 - 舊專案觀察過 `PrintWindow` 回傳一張不再更新的畫面:兩張截圖逐位元相同時,先別斷定操作沒生效, 用 MSAA 或 log 對照。 -- 輸出放 session 暫存目錄。身分頁顯示資料目錄的完整路徑(含使用者名稱),這種截圖不要貼進 - PR 或回報;其他頁面確認沒有個人資訊才附。 +- 輸出放 session 暫存目錄。確認畫面上沒有個人資訊(使用者名稱、帳號)才貼進 PR 或回報。 - 不要改用整螢幕截圖(`CopyFromScreen`):會截到蓋在上面的其他視窗。 ## 操作的陷阱 @@ -67,6 +66,16 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $S/window_shot.ps1 -Atta DPI 縮放下座標與截圖對不上。 - 對主視窗送 `WM_CLOSE` 才是「使用者按 X」;`Stop-Process` 是強殺,測不到關閉流程。 +- **中文輸入法**:這台機器預設是中文輸入法時,`SendKeys` 打的英數字會先進組字,第一個 Enter 只是送出 + 組字、第二個才觸發搜尋;空白鍵會拿去選字(「a」+空白鍵變「日」)。送出搜尋就連按兩次 Enter;要驗 + 「輸入框內空白鍵只輸入空格」看 log 裡沒有 `Playback state` 即可,或在 Android 用 `adb shell input text`。 +- **整合測試會蓋掉 dev 產物**:`flutter test integration_test/... -d windows` 建到同一個 + `build\windows\x64\dev\runner\Debug\fmp.exe`,跑完後那裡是測試版,還可能留下一個沒有視窗、沒有 log + 的 `fmp.exe`(占住單一實例,之後啟動會直接結束)。跑完整合測試先結束它,再 + `flutter build windows --flavor dev --debug`。 +- **Narrator**:`Start-Process narrator.exe` 會帶出「快速入門」視窗搶前景(之後的 `-Click` 回 exit 3), + `taskkill /F /IM NarratorQuickStart.exe` 關掉它;Narrator 本身 `taskkill` 會被拒,用 Win+Ctrl+Enter 關。 + ## SMTC(M2 起才有東西可讀) ```bash diff --git a/.claude/skills/verify-on-device/scripts/window_shot.ps1 b/.claude/skills/verify-on-device/scripts/window_shot.ps1 index 41d93cb8e..d5d52fc6c 100644 --- a/.claude/skills/verify-on-device/scripts/window_shot.ps1 +++ b/.claude/skills/verify-on-device/scripts/window_shot.ps1 @@ -1,7 +1,7 @@ # 只截 App 視窗(不含桌面與其他視窗,截圖不會帶到個人資訊),存成 PNG。 # # 兩種用法: -# 啟動並截圖: ... -Exe [-ArgLine '--fmp-dev-playback'] -Out [-Wait 8] [-KeepRunning] +# 啟動並截圖: ... -Exe [-ArgLine '--fmp-dev-plugin=<插件.js>'] -Out [-Wait 8] [-KeepRunning] # 接上已開的: ... -Attach -Out (找行程 -Proc、視窗標題 -Title 的, # 預設 fmp 與 FMP Dev;舊版與 prod 也叫 fmp.exe) # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cb17b273..b3513ff72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,6 +273,16 @@ jobs: - name: Unit and widget tests run: flutter test + # golden(alchemist 的 CI 版)比對失敗時,flutter_test 把差異圖寫在 + # 各 golden 目錄旁的 failures/;上傳才看得到哪裡不同。 + - name: Upload golden failures + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: app-golden-failures + path: app/test/**/failures/ + if-no-files-found: ignore + ci-result: name: CI Result if: always() diff --git a/.trellis/spec/app/playback/index.md b/.trellis/spec/app/playback/index.md index 4fd0aa6b7..b77ff09bf 100644 --- a/.trellis/spec/app/playback/index.md +++ b/.trellis/spec/app/playback/index.md @@ -13,8 +13,7 @@ lib/playback/ 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) + playback_providers.dart # audioBackendProvider、playbackControllerProvider、狀態/佇列/進度 stream backends/ audio_backend.dart # AudioBackend 介面、BackendSource、狀態與事件 backend_rules.dart # classifyTrackEnd、LookAheadEdit(兩個後端共用) @@ -79,24 +78,15 @@ lib/platform/audio/ # AudioBackendKind、PlayableFormat、PlaybackSuppor ## 實機驗證(ADR 0018 §如何確認) -測試插件的三首都是同一個 2 秒的 `tone.wav`(dev flavor 的 asset),不連網。 +建置、安裝、啟動、讀 log 與 `dumpsys audio` 照 `verify-on-device` skill(`.claude/skills/verify-on-device/`)。 +播放一律從 UI 開始:以 `--fmp-dev-plugin` 裝測試插件(重播,三首同一個 2 秒的 `tone.wav`、 +不連網)或 B 站插件(真實),搜尋後點一首。播放相關要看的: -- 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` 是上一首最後回報的位置)與接著的 +- 交接:`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:`。 +- Android 音訊焦點:播放中與交接前後,`Audio Focus stack` 的最上面一直是 + `com.personal.fmp.dev`(`AUDIOFOCUS_GAIN`),沒有被 abandon 又重新 request;播完之後也仍在 + (just_audio 不主動放)。模擬器要有聲音輸出(不是 `-no-audio` 啟動的),ExoPlayer 才會真的播。 +- 真實連線(ADR 0027 §決定 2 的最少操作):B 站播一首,看 `Opening stream` 的 `headers` 有 + `Referer`。 diff --git a/.trellis/spec/app/plugins/index.md b/.trellis/spec/app/plugins/index.md index 43c43e931..a95aaa679 100644 --- a/.trellis/spec/app/plugins/index.md +++ b/.trellis/spec/app/plugins/index.md @@ -125,7 +125,8 @@ export async function resolveStream({ sourceId, cid, formats }) { - 測試:`PluginHarness().load(source)`(`test/plugins/plugin_harness.dart`),HTTP 走假 adapter。 - dev 實機:Windows `flutter run --dart-entrypoint-args=--fmp-dev-plugin=<路徑>`,或設環境變數 - `FMP_DEV_PLUGIN`;Android 見 `app/AGENTS.md` § 插件的 `adb` 指令。身分頁列出載入的插件。 + `FMP_DEV_PLUGIN`;Android 見 `verify-on-device` skill 的 `references/android.md`。有 `search` + 能力的插件出現在搜尋頁的音源 chip。 ## 加一個宿主 API diff --git a/.trellis/spec/app/ui/index.md b/.trellis/spec/app/ui/index.md index 383e5f863..390cd025b 100644 --- a/.trellis/spec/app/ui/index.md +++ b/.trellis/spec/app/ui/index.md @@ -1,7 +1,7 @@ # 介面(`app/lib/ui/`、`app/lib/i18n/`) -寫畫面、加字串、跳提示時適用。規則(token、字串來源、提示入口)與閘門見 -`app/AGENTS.md` § 介面;為什麼是這些選擇,見 ADR 0023、ADR 0024 與 +寫畫面、加字串、跳提示、加快捷鍵時適用。規則(token、字串來源、提示入口、快捷鍵、焦點三區) +與閘門見 `app/AGENTS.md` § 介面;為什麼是這些選擇,見 ADR 0023、ADR 0024 與 `.trellis/tasks/archive/2026-09/09-30-ui-foundation/research/notes.md`。這裡只寫怎麼做。 ## 目錄 @@ -18,7 +18,12 @@ lib/ui/ i18n/ui_locale.dart # LocaleSetting → Flutter locale/slang/字型;translationsProvider errors/ # AppError → 訊息 toast/ # Toaster、ToastHost;fmp_toast_entry 的允許目錄 - settings/ # 設定頁的控制項 + shell/ # AppShell(導覽、內容、播放列三區)、快捷鍵表 + search/ # 搜尋頁、searchProvider、音源 chip 列 + settings/ # 設定頁(list-detail)與外觀的控制項 + player/ # 播放列、queueTracksProvider(佇列的顯示資料)、playTracks + artwork/ # 封面縮圖與 pickArtwork + format/ # 時長文字 lib/app/app_material.dart # 三個 App 根元件共用的 MaterialApp 設定 ``` @@ -50,8 +55,9 @@ switch (WindowClass.of(context)) { } ``` -- `WindowClass.of` 讀最近的 `WindowClassScope`;App 根放了一個(量整個視窗),外殼(12b) - 在內容區再放一個,頁面讀到的是內容區的等級。 +- `WindowClass.of` 讀最近的 `WindowClassScope`;App 根放了一個(量整個視窗,外殼用它選導覽 + 元件),外殼在內容區與播放列各放一個,頁面與播放列讀到的是自己那一塊的等級(視窗 900 寬時 + 內容區扣掉 rail 是 medium)。 - 只在等級改變時重建。要精確寬度的版面仍用 `LayoutBuilder`。 ## 字串 @@ -98,15 +104,50 @@ try { - `error` 自己呼叫 `log.report`,呼叫端不要再 report 一次。 - 每則最多一個動作;帶動作的也照時長消失(成功與資訊 4 秒、警告與錯誤 6 秒)。 - 去重 5 秒:訊息以「種類+文字」,錯誤以「類別+音源」。 -- 外殼(12b)以 `ref.read(toastBottomInsetProvider.notifier).set(高度)` 發佈從視窗底邊算起 - 被佔住的高度(含安全區);全螢幕頁不發佈時要設回 0。 +- 外殼的 `_BottomInsetReporter` 量底部那一塊(compact 是播放列加底部導覽列,更寬是播放列) + 的高度,排版後以 `toastBottomInsetProvider` 發佈;頁面不用管。M2 的全螢幕播放頁蓋住外殼時 + 要設回 0。 +- 要看狀態變化跳提示(播放停在 `Failed`),在 `ref.listen` 的 callback 裡呼叫 `Toaster`,不在 + `build` 裡:`Toaster` 同步送出,`ToastHost` 當場 `showSnackBar`。 - M1 沒有「詳細」與「回報」(ADR 0023 §決定 4 延到 M3,和 Debug 頁的錯誤歷史一起做)。 +## 外殼、快捷鍵與焦點 + +- 導覽項只有 `ShellDestination` 的兩個;頁面放在內容區的 `IndexedStack`(換頁不丟狀態,沒選的 + 頁面焦點被排除)。 +- 加一個 App 內快捷鍵(ADR 0024 §決定 8 的表): + 1. `shell_shortcuts.dart` 加一個 `Intent` 與 `shellShortcuts` 的一列; + 2. `AppShell` 的 `Actions` 接上。按鍵同時是文字編輯鍵(空白鍵、方向鍵、Home/End、 + Ctrl+A 之類)的,用 `TextInputAwareAction`,焦點在輸入框時停用、交給輸入框; + 3. 有對應按鈕的,翻譯檔的 `*Tooltip` 字串寫上按鍵(`播放(空白鍵)`),按鈕的語意標籤 + (`Icon.semanticLabel`)不帶按鍵; + 4. `test/ui/shell/app_shell_test.dart` 加案例,文字編輯鍵另外在輸入框裡按一次確認沒作用。 +- 焦點三區(導覽、內容、播放列)各是一個 `FocusScope` 加 `FocusTraversalGroup`:Tab 只在區內 + 循環;F6 回到那一區上次的焦點,沒有就是它的第一個可聚焦項目。新的可聚焦元件放在對的那一區裡。 + +## 播放列與封面 + +- 開始播放一律經 `playTracks(ref, tracks, index)`(`lib/ui/player/queue_tracks.dart`):它把 + 顯示資料放進 `queueTracksProvider`,再把整份清單交給 `PlaybackController`。播放列以曲目鍵查 + 顯示資料;M2 有曲目表之後改從那裡查。 +- 播放列的控制項照 ADR 0024 §決定 5 的三段,只放已經有的功能;加功能時同時改 + `player_bar_test.dart` 的 `controls per width` 與 golden。 +- 封面用 `ArtworkImage`:`pickArtwork` 挑一張、以顯示尺寸解碼,沒有、載入中與失敗都是同一個 + 佔位圖。網址在 DTO 解碼時已經過 `allowedHosts`;不帶 header(B 站的 hdslb 不帶 `Referer` 讀得到, + 帶別的網域反而 403)。 + ## 測試 - 畫面測試用 `buildAppTheme` 的主題;有提示的包 `ToastHost`,以 `toasterProvider.overrideWithValue(Toaster(...))` 注入(例子: `test/ui/toast/toast_host_test.dart`)。 +- 外殼與頁面用 `test/ui/support/shell_harness.dart` 的 `ShellHarness`:可搜尋、可解析的假插件 + (`searchSourcesProvider` override)、假後端上的真 `PlaybackController`、記憶體資料庫、英文 + 介面。`pumpShell` 開整個外殼,`pumpApp` 開單一個 widget,`play` 直接開始播。 +- golden(`alchemist`,只比 CI 版:文字畫成色塊、不畫陰影,`test/flutter_test_config.dart` + 關掉平台版):一個情境一個 `goldenTest`、不用 `GoldenTestScenario`(它的名稱標籤在 Windows 與 + Linux 差一個像素)。更新:`flutter test --update-goldens <檔案>`,產生的圖在旁邊的 + `goldens/ci/`,看過再提交。只守版面結構,數量保持少(ADR 0024 §如何確認)。 - 時間:`Toaster` 讀 `clock.now()`,單元測試用 `fakeAsync`,widget 測試的 `tester.pump(duration)` 也會推進它。 - 新畫面加進 guideline 測試(`test/ui/guidelines_test.dart` 的寫法):淺色、深色各跑 @@ -121,3 +162,4 @@ try { 乾淨。 - 新字串三個 JSON 都有、產生檔已重跑並提交。 - 新畫面在淺色、深色都過 guideline 測試。 +- 新的只有圖示的按鈕有 tooltip(有快捷鍵就附上)與語意標籤。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index d4c0ed857..e0f922b47 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 契約執行器)、#185(PR 9c 紀錄)、#186(遮蔽修正)、#187(PR 10 播放核心)、#188(YouTube.js 探針結論)。#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(遮蔽修正)、#187(PR 10 播放核心)、#188(YouTube.js 探針結論)、#189(PR 11 verify-on-device)、#190(PR 12a UI 基礎)。#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`,有變異驗證)。 @@ -39,7 +39,8 @@ - **YouTube.js 探針完成**:通過(VISIONOS client),M3 的 YouTube 走插件;程式碼在分支 `probe/youtubejs`(已 push,不合併),結論在 `.trellis/tasks/archive/2026-09/09-30-youtubejs-probe/`,ADR 0014 §決定 10 已補。Android 只驗到音訊系統層(模擬器 `-no-audio`)。 - **PR 11 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-verify-on-device/`):`.claude/skills/verify-on-device/`;實機驗證一律照它做,回報含平台與模式。Windows 腳本以視窗標題 `FMP Dev` 比對,避免點到同名 `fmp.exe` 的舊版。 - **PR 12a 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-ui-foundation/`):主題 token、`WindowClass`、slang 三語言、`Toaster`/`ToastHost`、`messageArgs` 收窄、外觀可清回跟隨系統。實機:Windows 繁中/English 正黑體、简中雅黑;Android 英文介面原本落到簡中字形,已改為主題文字樣式帶 `zh-Hant` 並複驗。 -- **下一步**:12b(`.trellis/tasks/09-30-app-shell`,prd 已核准) → 13 五平台建置與發版 workflow → 里程碑驗收。 +- **PR 12b 完成**(子任務已 archive 到 `.trellis/tasks/archive/2026-09/09-30-app-shell/`):外殼、搜尋、設定、播放列、快捷鍵與 F6;**M1 端到端驗收通過**(Windows、Android 模擬器各搜尋 B 站並連播兩首,模式:真實)。 +- **下一步**:13 五平台建置與發版 workflow → 里程碑驗收。 - **擁有者決定**:1–8 都在父任務 `prd.md`「擁有者的決定」。9a、9b 期間新增了三項: - 決定 6:插件安裝檔是單一 `.js`,開頭帶 `==FMP Plugin==` manifest; - 決定 7:插件在背景 isolate 執行;逾時先送存活探測,沒回應才停用到重啟; @@ -210,7 +211,15 @@ PR 10 留下的後續: - [ ] `app/android/app/src/main/AndroidManifest.xml` 沒有 `INTERNET` 權限(只有 `debug/`、`profile/` 有),release 建置連不了網路;PR 13 處理。 - [ ] 開不起來的串流在換過候選後對應 `Unsupported`,ADR 0013 會顯示成「視為 bug」的通用訊息;CDN 403 落到這裡不貼切,PR 12 做提示時再看。 -- [ ] 12a 審查留給 12b:`ToastHost` 多包的 `Overlay` 成了 root overlay,搜尋框的文字選取工具列、放大鏡要實機確認;鍵盤彈出時提示位置(`viewInsets` 減 `viewPadding`)沒有測試;`Toaster` 的 stream 同步發送,在 build 階段呼叫會出錯(接 `ref.listen` 時留意)。 +- [x] 12a 審查留給 12b(12b 已處理,有測試):`ToastHost` 多包的 `Overlay` 成了 root overlay,搜尋框的文字選取工具列、放大鏡要實機確認;鍵盤彈出時提示位置(`viewInsets` 減 `viewPadding`)沒有測試;`Toaster` 的 stream 同步發送,在 build 階段呼叫會出錯(接 `ref.listen` 時留意)。 + +12b 留下的後續: + +- [ ] 啟動 log 的 `App started` 帶 `dataDirectory`(含系統使用者名稱);本機 log 可以,但 ADR 0011 §決定 6 的診斷包不得含帳號名稱:M3 做診斷包時遮掉或去掉。 +- [ ] CDN 403 開不起來會對到 `Unsupported`,提示是「視為 bug」的通用訊息(PR 10 起的待辦,12b 未處理):M2 做恢復與提示時改對應。 +- [ ] 播放中「跳過並提示」:控制器沒有發出跳過事件,目前跳過不提示;M2。 +- [ ] 封面 `Image.network`:轉址的下一跳不經 `allowedHosts`、下載沒有大小上限與逾時;M6 的媒體 client 接手。 +- [ ] Android 返回鍵在任何分頁都直接離開 App(與舊版相同);M2 做播放頁時一併看。 - [ ] 前瞻開不起來時兩個後端的行為沒有契約案例(Android 會被當成目前這首中斷;Windows 可能卡在 Playing);前瞻解析比目前這首播完還慢時會多解析一次。M2 補契約案例。 - [ ] 被取代的 `resolveStream` 只丟結果、不取消網路工作(`SourcePlugin` 沒有取消參數)。 - [ ] `.trellis/spec/app/playback/index.md` 的「實機驗證」段與 `verify-on-device` skill 的建置、安裝、`am start` 步驟重複;改成指向 skill(PR 12 動到播放時順手)。 diff --git a/.trellis/tasks/09-30-app-shell/check.jsonl b/.trellis/tasks/09-30-app-shell/check.jsonl deleted file mode 100644 index e69de29bb..000000000 diff --git a/.trellis/tasks/09-30-app-shell/implement.jsonl b/.trellis/tasks/09-30-app-shell/implement.jsonl deleted file mode 100644 index e69de29bb..000000000 diff --git a/.trellis/tasks/archive/2026-09/09-30-app-shell/check.jsonl b/.trellis/tasks/archive/2026-09/09-30-app-shell/check.jsonl new file mode 100644 index 000000000..50bba7d72 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-app-shell/check.jsonl @@ -0,0 +1,10 @@ +{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Shell, breakpoints, player bar, search and settings layout, shortcuts"} +{"file": "docs/adr/0023-unified-toast.md", "reason": "Toast placement and bottom inset"} +{"file": "docs/adr/0018-playback-core.md", "reason": "PlaybackController as the only playback entry"} +{"file": "docs/adr/0027-on-device-verification.md", "reason": "On-device verification rules"} +{"file": ".trellis/spec/app/ui/index.md", "reason": "Tokens, translations, toasts"} +{"file": ".trellis/spec/app/playback/index.md", "reason": "PlaybackController API and dev entry"} +{"file": ".trellis/spec/app/plugins/index.md", "reason": "SourcePlugin search DTOs"} +{"file": ".trellis/spec/app/settings/index.md", "reason": "Appearance settings"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Design token and toast lints"} +{"file": ".claude/skills/verify-on-device/SKILL.md", "reason": "Verification steps to update when the identity page goes"} diff --git a/.trellis/tasks/archive/2026-09/09-30-app-shell/implement.jsonl b/.trellis/tasks/archive/2026-09/09-30-app-shell/implement.jsonl new file mode 100644 index 000000000..50bba7d72 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-30-app-shell/implement.jsonl @@ -0,0 +1,10 @@ +{"file": "docs/adr/0024-ui-ux-design-system.md", "reason": "Shell, breakpoints, player bar, search and settings layout, shortcuts"} +{"file": "docs/adr/0023-unified-toast.md", "reason": "Toast placement and bottom inset"} +{"file": "docs/adr/0018-playback-core.md", "reason": "PlaybackController as the only playback entry"} +{"file": "docs/adr/0027-on-device-verification.md", "reason": "On-device verification rules"} +{"file": ".trellis/spec/app/ui/index.md", "reason": "Tokens, translations, toasts"} +{"file": ".trellis/spec/app/playback/index.md", "reason": "PlaybackController API and dev entry"} +{"file": ".trellis/spec/app/plugins/index.md", "reason": "SourcePlugin search DTOs"} +{"file": ".trellis/spec/app/settings/index.md", "reason": "Appearance settings"} +{"file": ".trellis/spec/app/lints/index.md", "reason": "Design token and toast lints"} +{"file": ".claude/skills/verify-on-device/SKILL.md", "reason": "Verification steps to update when the identity page goes"} diff --git a/.trellis/tasks/09-30-app-shell/prd.md b/.trellis/tasks/archive/2026-09/09-30-app-shell/prd.md similarity index 100% rename from .trellis/tasks/09-30-app-shell/prd.md rename to .trellis/tasks/archive/2026-09/09-30-app-shell/prd.md diff --git a/.trellis/tasks/09-30-app-shell/task.json b/.trellis/tasks/archive/2026-09/09-30-app-shell/task.json similarity index 87% rename from .trellis/tasks/09-30-app-shell/task.json rename to .trellis/tasks/archive/2026-09/09-30-app-shell/task.json index 161be77ee..50e3d1aa0 100644 --- a/.trellis/tasks/09-30-app-shell/task.json +++ b/.trellis/tasks/archive/2026-09/09-30-app-shell/task.json @@ -3,7 +3,7 @@ "name": "app-shell", "title": "外殼、搜尋、設定與播放列", "description": "M1 PR 12b: adaptive shell, search page, appearance settings, player bar, shortcuts and F6, end-to-end Bilibili playback", - "status": "planning", + "status": "completed", "dev_type": null, "scope": null, "package": "app", @@ -11,8 +11,8 @@ "creator": "1morr", "assignee": "1morr", "createdAt": "2026-09-30", - "completedAt": null, - "branch": null, + "completedAt": "2026-09-30", + "branch": "feat/app-shell", "base_branch": "main", "worktree_path": null, "commit": null, diff --git a/app/.gitignore b/app/.gitignore index d77f79ca7..e2fc9925a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -50,3 +50,6 @@ app.*.map.json # 根目錄的 .gitignore 忽略 *.g.dart(舊專案不提交產生檔);app/ 提交 drift # 產生的程式碼,CI 檢查它與原始碼一致(app/AGENTS.md § 資料層)。 !*.g.dart + +# golden 比對失敗時 flutter_test 寫出的差異圖(在各 golden 目錄旁)。 +**/failures/ diff --git a/app/AGENTS.md b/app/AGENTS.md index 4159aaa4b..668644ff7 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -15,6 +15,7 @@ | drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build --delete-conflicting-outputs`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | | 翻譯(`lib/i18n/*.i18n.json`)或 `slang.yaml` | 先 `dart run slang`,再跑第一列 | | 播放後端(`lib/playback/backends/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/audio_backend_contract_test.dart -d <裝置>`(見 § 播放) | +| 提示宿主或外殼(`lib/ui/toast/`、`lib/ui/shell/`、`lib/app/`) | 第一列,加 Windows 與 Android 模擬器各跑一次 `flutter test integration_test/toast_layering_test.dart -d <裝置>`(提示在對話框、全螢幕頁之上,ADR 0023 §如何確認) | - `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 與產生檔檢查(見「資料層」); @@ -41,6 +42,10 @@ 每個案例的 fixture 整組重寫;有 `meta.edited` 的案例略過(手寫的錯誤案例不被蓋掉);結果不符 checks.json 期望的案例(例如連線失敗)什麼都不寫、原本的檔案不動。閘門:`record_test.dart`。 錄完同一個測試以重播再跑一次。 +- golden 測試(`alchemist`)在裸 `flutter test` 裡,只比 CI 版(文字畫成色塊,Windows 產生的圖 + 在 CI 的 Linux 上逐像素相同;平台版在 `test/flutter_test_config.dart` 關掉)。改了版面就 + `flutter test --update-goldens <那個測試檔>`,看過 `goldens/ci/` 的圖再提交;比對失敗的差異圖 + 寫在旁邊的 `failures/`(gitignore),CI 失敗時上傳成 artifact。 - 插件執行環境的實機量測:`flutter test integration_test/plugin_runtime_benchmark_test.dart -d <裝置>` (dev flavor;結果是 `FMP_BENCH` 開頭的行)。數字與方法在 `.trellis/tasks/archive/2026-09/09-30-js-runtime/research/notes.md` §4。 @@ -50,13 +55,13 @@ 操作步驟在 skill `.claude/skills/verify-on-device/`;根目錄的 `verify-legacy-on-device` 只給舊專案。 規則是 ADR 0027,實機驗證無法寫成測試,守它的是 review: -- **預設重播**:dev flavor 加內附測試插件(`--fmp-dev-playback`);App 有每插件的重播開關 - 之後也可以用它(§決定 1)。 +- **預設重播**:dev flavor 加內附測試插件(以 `--fmp-dev-plugin` 安裝 `fmp-test`,操作走 UI); + App 有每插件的重播開關之後也可以用它(§決定 1)。 - **真實連線的條件**:改動本身是插件、網路層、登入,或正在錄 fixture;只做最少的操作, 不批次、不迴圈(§決定 2)。 - **每個使用者看得到的 PR 都要在 Android 模擬器與 Windows 各驗一次**(§決定 3)。 - **回報要有「平台」與「模式:重播/真實」**,真實時列出做了哪些請求;缺任一項 review 退回。 - 截圖與回報不得含個人資訊(Windows 的身分頁會印出含使用者名稱的資料目錄路徑)。 + 截圖與回報不得含個人資訊(log 的 `App started` 帶含使用者名稱的資料目錄路徑)。 - 驗證只用 dev flavor;模擬器上的舊版 `com.personal.fmp` 不碰,prod APK 不安裝。 ## App 身分 @@ -293,7 +298,10 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 確認(ADR 0014 §決定 6),參數與環境變數都能由別的程式帶入。`devPluginPath` 在 prod 一律回 `null`。閘門:`plugin_installer_test.dart` 的 `development entry`(含 `prod reads neither…`)。 - 測試插件 `test/fixtures/plugins/test_plugin/`(`fmp-test`)只以 dev flavor 的 asset 打包,串流指向 - 同目錄的 `tone.wav`(`asset:///…`)。prod 的建置只留下空目錄,沒有檔案。第二個測試插件 + 同目錄的 `tone.wav`(`asset:///…`)。prod 的建置只留下空目錄,沒有檔案。實機以 + `--fmp-dev-plugin` 裝它的 `.js`,搜尋任何關鍵字都有結果、都播得出來;關鍵字剛好是 `fail` + 時以 `RateLimited` 失敗(離線看錯誤提示)。閘門: + `test/plugins/test_plugin_bundle_test.dart`。第二個測試插件 `http_test_plugin/`(`fmp-test-http`)會發請求(`*.fmp.test`),只給契約執行器,不打包。 - 插件目錄(契約檢查的單位):剛好一個 `.js` 安裝檔、`checks.json`(鍵是能力名稱,所以每能力最多 一條;只收 `SourcePlugin` 已有方法的能力)、`fixtures/<能力>/*.json`(依檔名是請求順序)。格式 @@ -355,14 +363,15 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 恢復(ADR 0018 §決定 7 的 M1 部分):網路錯誤、限流、中斷與提前結束從目前位置重試 1/3/9 秒;開不起來換下一個候選一次;其他錯誤類別跳過;連續跳過達佇列長度(最多 10) 停在 `Failed`。M1 沒有連線偵測、試聽片段設定(一律跳過)、緩衝飢餓與輸出裝置的處理、 - 「正常播放 10 秒後重試計數歸零」(M1 換歌才歸零),也沒有提示 UI(PR 12b)。閘門:`recovery_policy_test.dart`、`playback_controller_test.dart` - 的 `recovery` 群組。 + 「正常播放 10 秒後重試計數歸零」(M1 換歌才歸零)。停在 `Failed` 時外殼提示一次(跳過不提示, + 控制器沒有發出跳過的事件)。閘門:`recovery_policy_test.dart`、`playback_controller_test.dart` + 的 `recovery` 群組、`app_shell_test.dart` 的 `playback that stops failed shows a toast`。 - 被取代的解析結果丟掉,但插件的 `resolveStream` 沒有取消參數,網路工作不取消 (ADR 0018 §決定 6 的取消等插件 API 支援)。已知限制。 -- 播放的開發入口:dev flavor 帶 `--fmp-dev-playback` 啟動就安裝內附的測試插件並依序播 - 它的三首;`--fmp-dev-playback=<曲目鍵>`(可重複;不用逗號,Android 的 `--esal` 以逗號切 - 陣列)播指定的曲目(插件同時以 `--fmp-dev-plugin` 安裝)。prod 不讀,理由同插件的開發入口。PR 12b 的播放列能走同一條路後刪掉。閘門: - `dev_playback_entry_test.dart` 的 `prod reads nothing`。 +- UI 開始播放只經 `playTracks`(`lib/ui/player/queue_tracks.dart`):整份清單與起點交給 + `playQueue`,顯示資料放 `queueTracksProvider`(M1 沒有曲目表,播放列以曲目鍵查它)。閘門: + `search_page_test.dart` 的 `tapping a result plays the whole list from it`。沒有播放的開發 + 入口:實機驗證從搜尋頁點一首。 ## 設定 @@ -388,11 +397,11 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 - 間距、圓角、顏色只從 `lib/ui/theme/`(`AppTokens`、`AppLayout`、`ColorScheme`)取,字級只用 `TextTheme` 的角色。閘門:lint `fmp_design_tokens`(`lib/ui/`,theme 目錄豁免)。`lib/app/` - 不在它的範圍;身分頁(12b 換掉)照樣用 token,但沒有閘門。 + 不在它的範圍,畫面都放 `lib/ui/`。 - 使用者看到的字串只來自 `lib/i18n/*.i18n.json`;base locale 是 zh-TW,缺字在執行時退回繁中, 所以編譯擋不住漏翻。閘門:`test/i18n/translations_test.dart`(三個語言的 key 與 `{參數}` 相同、每個 `ErrorMessageKey`/`UnavailableReason` 都有字串;含變異案例)。widget 裡寫死的 - 字串沒有閘門,review 時看;身分頁的 `Dev playback:` 之類是開發用標籤,刻意不翻。 + 字串沒有閘門,review 時看;時長(`3:05`、未知的 `-:--`)與語言名稱刻意不翻。 - 翻譯只經 `translationsProvider`(`lib/ui/i18n/ui_locale.dart`)注入:`slang.yaml` 設 `locale_handling: false`,slang 不產生全域 `t`/`LocaleSettings`,語言狀態只有外觀設定一份。 - `MaterialApp.locale` 一律給帶書寫系統的 locale(`zh-Hant-TW`、`zh-Hans-CN`、`en`),由 @@ -403,8 +412,8 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 的 `appearance` 群組。 - 主題的 `fontFamilyFallback` 取平台層依介面語言排序的清單(ADR 0024 §決定 2);App 啟動與每次 換語言寫一筆 `UI locale applied`(`locale`、`fontFallback`)。閘門:`fmp_app_test.dart` 的 - `the theme uses the platform fonts for the UI language`。字形是否正確只能實機看(身分頁的 - 字形樣本)。 + `the theme uses the platform fonts for the UI language`。字形是否正確只能實機看(設定頁的 + 語言名稱、切到繁中與簡中介面)。 - 主題的每個文字樣式帶 `textLocaleOf` 的 locale(中文介面同介面語言,英文介面是繁中): Android 不指名字型,英文介面的漢字不帶它就落到簡中字形。樣式的 locale 蓋過 `Text.locale`, 要另一種字形的文字在樣式上指定。閘門:`app_theme_test.dart` 的 @@ -418,10 +427,39 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 `Scaffold` 包住 Navigator:提示在全螢幕頁、對話框、底部面板之上,一次一則、新的取代舊的, 時長 4/6 秒、帶動作也照時長消失、無障礙導覽時停留並有關閉鈕、App 在背景(hidden/paused) 不顯示,位置避開 `toastBottomInsetProvider`。閘門:`test/ui/toast/toast_host_test.dart`。 -- 淺色與深色主題下,示範畫面、外觀設定控制項與四種提示通過點擊區與對比度 guideline。閘門: - `test/ui/guidelines_test.dart`。12b 的正式頁面要各自加進去。 + `Toaster` 同步送出:看狀態變化跳提示時在 `ref.listen` 的 callback 呼叫,不在 `build` 裡。 +- 淺色與深色主題下,示範畫面、四種提示,以及外殼裡的搜尋頁(搜尋前、有結果加播放列)與設定頁 + 在窄(400)與寬(1000)視窗通過點擊區與對比度 guideline。閘門:`test/ui/guidelines_test.dart`。 + 新頁面要加進去。搜尋框因此用 `TextField` 而不是 M3 的 `SearchBar`(後者整條可點的那層沒有 + 語意名稱、輸入框只有 24dp 高)。 - `WindowClass` 與 M3 同值(600/840/1200/1600,下限含在高的一級)。閘門: `test/ui/layout/window_class_test.dart`。 +- 外殼 `AppShell`(`lib/ui/shell/`)依整個視窗的等級換導覽:compact 底部 `NavigationBar`(播放列 + 在它上面)、medium 與 expanded `NavigationRail`、large 以上常駐 `NavigationDrawer`;都是 Material + 內建元件(ADR 否決 `flutter_adaptive_scaffold`)。播放列在內容區下方、與內容區同寬,佇列是空的時 + 不佔位置。閘門:`test/ui/shell/app_shell_test.dart` 的 `navigation per window class`。 +- 外殼量底部被佔住的高度(播放列+底部導覽列+安全區)發佈給 `toastBottomInsetProvider`;頁面 + 不發佈。閘門:同檔的 `the bottom inset for toasts`、`toast_host_test.dart` 的 `position` 群組 + (含鍵盤:位移是鍵盤高度減 `viewPadding`)。 +- 播放列的控制項依它自己的寬度分三段(ADR 0024 §決定 5,只放 M1 有的):< 600 播放、下一首; + 600 以上加上一首;曲名至少 160dp。閘門:`test/ui/player/player_bar_test.dart` 的 + `controls per width`(599/600/839/840 等邊界)、golden `player_bar_golden_test.dart`(三個寬度, + 只守版面結構)。 +- App 內快捷鍵(ADR 0024 §決定 8)只在 `lib/ui/shell/shell_shortcuts.dart` 的表,綁在外殼的 + `Shortcuts`:空白鍵、Ctrl+←/→、Shift+←/→(5 秒)、Ctrl+F、Ctrl+,、F6。文字編輯的快捷鍵 + (`DefaultTextEditingShortcuts`)由 `WidgetsApp` 放在 App 根、比外殼遠,外殼會先接走按鍵;所以 + 同時是文字編輯鍵的那幾個用 `TextInputAwareAction`,焦點在輸入框時停用、按鍵交還輸入框。閘門: + `app_shell_test.dart` 的 `shortcuts` 群組(`text-editing keys in the search field stay in the + field`:輸入框裡的空白鍵、Ctrl/Shift 加方向鍵不動播放)。 +- 焦點三區(導覽、內容、播放列)各是 `FocusScope`+`FocusTraversalGroup`:Tab 只在區內循環,F6 + 依序換區、跳過不在畫面上的播放列。只有圖示的按鈕有 tooltip(附按鍵)與語意標籤。閘門:同檔的 + `focus regions` 群組、guideline 測試(標籤);tooltip 附按鍵沒有閘門,review 時看。 +- `ToastHost` 的 `Overlay` 是 root overlay,文字選取工具列與放大鏡插在那裡,照常運作。閘門: + `search_page_test.dart` 的 `text selection over the toast host`(Android 長按與放大鏡、Windows + 右鍵選單)。 +- 封面以 `Image.network` 直接讀(網址已過 `allowedHosts`,不帶 header、沒有 cookie),只有 Flutter + 記憶體的 `ImageCache`;轉址由 `HttpClient` 自己跟,下一跳不經 `allowedHosts`。磁碟快取與經媒體 + client 讀圖(每跳檢查)在 M6(ADR 0016 §決定 4)。沒有閘門,review 時看。 ## 零聯網 diff --git a/app/dart_test.yaml b/app/dart_test.yaml index 13627d432..9aedb9b64 100644 --- a/app/dart_test.yaml +++ b/app/dart_test.yaml @@ -5,3 +5,6 @@ tags: # (flutter test 沒有 package:test 的 --preset,解除只能靠 --run-skipped。) live: skip: "live: hits real APIs; run with --run-skipped --tags live" + # alchemist 的 golden 測試(goldenTest 自動加上)。和其他測試一起跑:CI 版的 + # golden(文字畫成色塊)在各平台相同,見 test/flutter_test_config.dart。 + golden: {} diff --git a/app/integration_test/toast_layering_test.dart b/app/integration_test/toast_layering_test.dart new file mode 100644 index 000000000..abb6bb0c8 --- /dev/null +++ b/app/integration_test/toast_layering_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/app_material.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/shell/app_shell.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../test/ui/support/shell_harness.dart'; + +// ADR 0023 §如何確認的實機那一項:提示在全螢幕頁與對話框之上看得到。外殼、 +// ToastHost 與 MaterialApp 和 App 相同(FmpApp 的 builder),視窗是裝置自己的 +// 大小;插件與播放後端用測試的假實作(不連網、不出聲)。同一組斷言在 +// `test/ui/toast/toast_host_test.dart` 的 `above every route` 以 flutter test +// 跑;這裡在真的引擎與平台上再跑一次: +// +// flutter test integration_test/toast_layering_test.dart -d windows +// flutter test integration_test/toast_layering_test.dart -d emulator-5554 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + /// 開外殼;回傳外殼的 context(開對話框、推路由用)。 + Future<(ShellHarness, BuildContext)> pumpShell(WidgetTester tester) async { + final h = ShellHarness(); + await tester.pumpWidget( + ProviderScope( + overrides: h.overrides, + child: fmpMaterialApp( + title: 'FMP Dev', + locale: LocaleSetting.en, + themeMode: ThemeMode.light, + fontFamilyFallback: const [], + builder: (context, navigator) => + WindowClassScope(child: ToastHost(child: navigator!)), + home: const AppShell(), + ), + ), + ); + await tester.pumpAndSettle(); + return (h, tester.element(find.byType(AppShell))); + } + + /// 送出一則提示,斷言它在最上層:點在文字中央打到的是提示本身,沒有被 + /// 路由或遮罩蓋住,而且整則在視窗內。 + Future expectToastOnTop(WidgetTester tester, ShellHarness h) async { + const message = 'Toast above the route'; + h.toaster.warning(message); + await tester.pumpAndSettle(); + expect(find.text(message).hitTestable(), findsOneWidget); + final view = tester.view; + final window = Offset.zero & (view.physicalSize / view.devicePixelRatio); + final toast = tester.getRect( + find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ), + ); + expect(window.contains(toast.topLeft), isTrue, reason: '$toast in $window'); + expect( + window.contains(toast.bottomRight - const Offset(1, 1)), + isTrue, + reason: '$toast in $window', + ); + } + + testWidgets('a toast shows above a dialog', (tester) async { + final (h, shell) = await pumpShell(tester); + unawaited( + showDialog( + context: shell, + builder: (_) => const AlertDialog(content: Text('A dialog')), + ), + ); + await tester.pumpAndSettle(); + + await expectToastOnTop(tester, h); + expect(find.text('A dialog'), findsOneWidget); + }); + + testWidgets('a toast shows above a full-screen route', (tester) async { + final (h, shell) = await pumpShell(tester); + unawaited( + Navigator.of(shell).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => + const Scaffold(body: Center(child: Text('A full-screen page'))), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectToastOnTop(tester, h); + expect(find.text('A full-screen page'), findsOneWidget); + }); +} diff --git a/app/lib/app/fmp_app.dart b/app/lib/app/fmp_app.dart index 290d4d8e7..df1c98e1f 100644 --- a/app/lib/app/fmp_app.dart +++ b/app/lib/app/fmp_app.dart @@ -4,30 +4,17 @@ import 'package:material_ui/material_ui.dart'; import 'package:fmp/app/app_material.dart'; import 'package:fmp/core/app_flavor.dart'; import 'package:fmp/core/core_providers.dart'; -import 'package:fmp/core/errors/app_error.dart'; import 'package:fmp/domain/appearance.dart'; -import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; -import 'package:fmp/platform/fonts/fonts.dart'; import 'package:fmp/platform/platform_capabilities.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'; import 'package:fmp/settings/appearance_settings.dart'; import 'package:fmp/ui/i18n/ui_locale.dart'; import 'package:fmp/ui/layout/window_class.dart'; -import 'package:fmp/ui/settings/appearance_controls.dart'; +import 'package:fmp/ui/shell/app_shell.dart'; import 'package:fmp/ui/theme/app_theme.dart'; -import 'package:fmp/ui/theme/app_tokens.dart'; import 'package:fmp/ui/toast/toast_host.dart'; -/// App 根元件:主題、介面語言與提示宿主(ADR 0023、0024)。 -/// -/// 畫面目前只有身分頁:App 名稱、flavor、資料目錄、外觀設定、字形樣本與載入的 -/// 插件,供實機確認身分、開發入口(插件、播放)與 CJK 字形;正式的外殼在 M1 -/// PR 12b。 +/// App 根元件:主題、介面語言、提示宿主與外殼(ADR 0023、0024)。 class FmpApp extends ConsumerStatefulWidget { const FmpApp({super.key, required this.flavor}); @@ -58,6 +45,13 @@ class _FmpAppState extends ConsumerState { }, ); }, fireImmediately: true); + // 插件的開發入口(只在 dev 有路徑):啟動時安裝一次。結果寫進 log,裝好 + // 的插件出現在搜尋頁的音源列。 + ref.listenManual( + devPluginInstallProvider, + (_, _) {}, + fireImmediately: true, + ); } @override @@ -72,155 +66,7 @@ class _FmpAppState extends ConsumerState { fontFamilyFallback: ref.watch(fontFamilyFallbackProvider), builder: (context, navigator) => WindowClassScope(child: ToastHost(child: navigator!)), - home: _IdentityPage(flavor: widget.flavor), - ); - } -} - -class _IdentityPage extends ConsumerWidget { - const _IdentityPage({required this.flavor}); - - final AppFlavor flavor; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final spacing = AppTokens.of(context).spacing; - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.all(spacing.x4), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - flavor.displayName, - style: Theme.of(context).textTheme.headlineMedium, - ), - Text(flavor.name), - SelectableText(ref.watch(dataDirectoryProvider).path), - const _PluginList(), - const _DevPlayback(), - SizedBox(height: spacing.x4), - const _FontSample(), - SizedBox(height: spacing.x4), - const AppearanceControls(), - ], - ), - ), - ), - ), - ); - } -} - -/// 實機比對 CJK 字形用(M1 PR 12a):同一串字以介面的樣式、指定繁中、指定 -/// 簡中各顯示一行。 -/// -/// 指定的那兩行帶該語言的 locale 與平台的字型清單,是參照;第一行(介面實際 -/// 用的樣式)應該和介面語言那一行相同。挑的字在台灣與大陸的標準字形不同: -/// 「草」的艹、「骨」上半、「令」的末筆、「值」「角」「這」「說」。 -class _FontSample extends ConsumerWidget { - const _FontSample(); - - static const _sample = '草骨令值角這說'; - static const _traditional = Locale.fromSubtags( - languageCode: 'zh', - scriptCode: 'Hant', - ); - static const _simplified = Locale.fromSubtags( - languageCode: 'zh', - scriptCode: 'Hans', - ); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final style = Theme.of(context).textTheme.headlineSmall!; - final fonts = ref.watch(platformCapabilitiesProvider).fontFallback; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('UI $_sample', style: style), - Text( - 'zh-Hant $_sample', - style: style.copyWith( - locale: _traditional, - fontFamilyFallback: fonts.familiesFor(FontLanguage.zhTw), - ), - ), - Text( - 'zh-Hans $_sample', - style: style.copyWith( - locale: _simplified, - fontFamilyFallback: fonts.familiesFor(FontLanguage.zhCn), - ), - ), - ], - ); - } -} - -/// 載入的插件(`id version`,沒有回應的加註),以及開發入口安裝失敗時的錯誤類別。 -class _PluginList extends ConsumerWidget { - const _PluginList(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final install = ref.watch(devPluginInstallProvider); - final plugins = ref.watch(pluginRegistryProvider); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (install case AsyncError(error: final AppError error)) - Text('Dev plugin: ${error.typeName}'), - ...switch (plugins) { - AsyncData(:final value) => [ - for (final plugin in value.values) - Text( - '${plugin.manifest.id} ${plugin.manifest.version}' - '${plugin.health == PluginHealth.unresponsive ? ' (unresponsive)' : ''}', - ), - ], - AsyncError(:final error) => [Text('Plugins: $error')], - _ => const [], - }, - ], - ); - } -} - -/// 播放的開發入口(只在帶了 `--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}'}', + home: const AppShell(), ); } } diff --git a/app/lib/i18n/en.i18n.json b/app/lib/i18n/en.i18n.json index a060f04c9..38fc71482 100644 --- a/app/lib/i18n/en.i18n.json +++ b/app/lib/i18n/en.i18n.json @@ -3,6 +3,41 @@ "databaseError": "Can't open the database", "unsupportedPlatform": "This platform isn't supported yet" }, + "shell": { + "search": "Search", + "settings": "Settings", + "searchTooltip": "Search (Ctrl+F)", + "settingsTooltip": "Settings (Ctrl+,)" + }, + "search": { + "hint": "Search songs", + "clear": "Clear", + "loadingSources": "Loading sources", + "noSources": "No sources to search", + "noSourcesHint": "Install a plugin that supports search to search here.", + "prompt": "Type a keyword to search", + "loading": "Searching", + "noResults": "No results for “{keyword}”", + "failed": "Search failed", + "retry": "Retry", + "loadMore": "Load more" + }, + "settings": { + "title": "Settings", + "appearance": "Appearance" + }, + "player": { + "play": "Play", + "pause": "Pause", + "previous": "Previous", + "next": "Next", + "playTooltip": "Play (Space)", + "pauseTooltip": "Pause (Space)", + "previousTooltip": "Previous (Ctrl+←)", + "nextTooltip": "Next (Ctrl+→)", + "progress": "Playback position", + "loading": "Loading" + }, "appearance": { "theme": "Theme", "themeSystem": "System", diff --git a/app/lib/i18n/strings.g.dart b/app/lib/i18n/strings.g.dart index 9b3deadb1..fd171b217 100644 --- a/app/lib/i18n/strings.g.dart +++ b/app/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 72 (24 per locale) +/// Strings: 153 (51 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/app/lib/i18n/strings_en.g.dart b/app/lib/i18n/strings_en.g.dart index df8fd0ef9..544e16ede 100644 --- a/app/lib/i18n/strings_en.g.dart +++ b/app/lib/i18n/strings_en.g.dart @@ -52,6 +52,16 @@ class TranslationsEn extends Translations @override late final Translations$startup$en startup = Translations$startup$en._(_root); @override + late final Translations$shell$en shell = Translations$shell$en._(_root); + @override + late final Translations$search$en search = Translations$search$en._(_root); + @override + late final Translations$settings$en settings = Translations$settings$en._( + _root, + ); + @override + late final Translations$player$en player = Translations$player$en._(_root); + @override late final Translations$appearance$en appearance = Translations$appearance$en._(_root); @override @@ -73,6 +83,105 @@ class Translations$startup$en extends Translations$startup$zh_TW { String get unsupportedPlatform => 'This platform isn\'t supported yet'; } +// Path: shell +class Translations$shell$en extends Translations$shell$zh_TW { + Translations$shell$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get search => 'Search'; + @override + String get settings => 'Settings'; + @override + String get searchTooltip => 'Search (Ctrl+F)'; + @override + String get settingsTooltip => 'Settings (Ctrl+,)'; +} + +// Path: search +class Translations$search$en extends Translations$search$zh_TW { + Translations$search$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get hint => 'Search songs'; + @override + String get clear => 'Clear'; + @override + String get loadingSources => 'Loading sources'; + @override + String get noSources => 'No sources to search'; + @override + String get noSourcesHint => + 'Install a plugin that supports search to search here.'; + @override + String get prompt => 'Type a keyword to search'; + @override + String get loading => 'Searching'; + @override + String noResults({required Object keyword}) => 'No results for “${keyword}”'; + @override + String get failed => 'Search failed'; + @override + String get retry => 'Retry'; + @override + String get loadMore => 'Load more'; +} + +// Path: settings +class Translations$settings$en extends Translations$settings$zh_TW { + Translations$settings$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get title => 'Settings'; + @override + String get appearance => 'Appearance'; +} + +// Path: player +class Translations$player$en extends Translations$player$zh_TW { + Translations$player$en._(TranslationsEn root) + : this._root = root, + super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override + String get play => 'Play'; + @override + String get pause => 'Pause'; + @override + String get previous => 'Previous'; + @override + String get next => 'Next'; + @override + String get playTooltip => 'Play (Space)'; + @override + String get pauseTooltip => 'Pause (Space)'; + @override + String get previousTooltip => 'Previous (Ctrl+←)'; + @override + String get nextTooltip => 'Next (Ctrl+→)'; + @override + String get progress => 'Playback position'; + @override + String get loading => 'Loading'; +} + // Path: appearance class Translations$appearance$en extends Translations$appearance$zh_TW { Translations$appearance$en._(TranslationsEn root) diff --git a/app/lib/i18n/strings_zh_CN.g.dart b/app/lib/i18n/strings_zh_CN.g.dart index 7109c63aa..04f4eae18 100644 --- a/app/lib/i18n/strings_zh_CN.g.dart +++ b/app/lib/i18n/strings_zh_CN.g.dart @@ -53,6 +53,19 @@ class TranslationsZhCn extends Translations late final Translations$startup$zh_CN startup = Translations$startup$zh_CN.internal(_root); @override + late final Translations$shell$zh_CN shell = Translations$shell$zh_CN.internal( + _root, + ); + @override + late final Translations$search$zh_CN search = + Translations$search$zh_CN.internal(_root); + @override + late final Translations$settings$zh_CN settings = + Translations$settings$zh_CN.internal(_root); + @override + late final Translations$player$zh_CN player = + Translations$player$zh_CN.internal(_root); + @override late final Translations$appearance$zh_CN appearance = Translations$appearance$zh_CN.internal(_root); @override @@ -75,6 +88,104 @@ class Translations$startup$zh_CN extends Translations$startup$zh_TW { String get unsupportedPlatform => '暂不支持此平台'; } +// Path: shell +class Translations$shell$zh_CN extends Translations$shell$zh_TW { + Translations$shell$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get search => '搜索'; + @override + String get settings => '设置'; + @override + String get searchTooltip => '搜索(Ctrl+F)'; + @override + String get settingsTooltip => '设置(Ctrl+,)'; +} + +// Path: search +class Translations$search$zh_CN extends Translations$search$zh_TW { + Translations$search$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get hint => '搜索歌曲'; + @override + String get clear => '清除'; + @override + String get loadingSources => '正在加载音源'; + @override + String get noSources => '没有可以搜索的音源'; + @override + String get noSourcesHint => '安装支持搜索的插件后,就能在这里搜索'; + @override + String get prompt => '输入关键词开始搜索'; + @override + String get loading => '正在搜索'; + @override + String noResults({required Object keyword}) => '找不到“${keyword}”的结果'; + @override + String get failed => '搜索失败'; + @override + String get retry => '重试'; + @override + String get loadMore => '加载更多'; +} + +// Path: settings +class Translations$settings$zh_CN extends Translations$settings$zh_TW { + Translations$settings$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get title => '设置'; + @override + String get appearance => '外观'; +} + +// Path: player +class Translations$player$zh_CN extends Translations$player$zh_TW { + Translations$player$zh_CN.internal(TranslationsZhCn root) + : this._root = root, + super.internal(root); + + final TranslationsZhCn _root; // ignore: unused_field + + // Translations + @override + String get play => '播放'; + @override + String get pause => '暂停'; + @override + String get previous => '上一首'; + @override + String get next => '下一首'; + @override + String get playTooltip => '播放(空格键)'; + @override + String get pauseTooltip => '暂停(空格键)'; + @override + String get previousTooltip => '上一首(Ctrl+←)'; + @override + String get nextTooltip => '下一首(Ctrl+→)'; + @override + String get progress => '播放进度'; + @override + String get loading => '正在加载'; +} + // Path: appearance class Translations$appearance$zh_CN extends Translations$appearance$zh_TW { Translations$appearance$zh_CN.internal(TranslationsZhCn root) diff --git a/app/lib/i18n/strings_zh_TW.g.dart b/app/lib/i18n/strings_zh_TW.g.dart index 4a8beff20..8d8b8a066 100644 --- a/app/lib/i18n/strings_zh_TW.g.dart +++ b/app/lib/i18n/strings_zh_TW.g.dart @@ -44,6 +44,15 @@ class Translations with BaseTranslations { // Translations late final Translations$startup$zh_TW startup = Translations$startup$zh_TW.internal(_root); + late final Translations$shell$zh_TW shell = Translations$shell$zh_TW.internal( + _root, + ); + late final Translations$search$zh_TW search = + Translations$search$zh_TW.internal(_root); + late final Translations$settings$zh_TW settings = + Translations$settings$zh_TW.internal(_root); + late final Translations$player$zh_TW player = + Translations$player$zh_TW.internal(_root); late final Translations$appearance$zh_TW appearance = Translations$appearance$zh_TW.internal(_root); late final Translations$errors$zh_TW errors = @@ -65,6 +74,123 @@ class Translations$startup$zh_TW { String get unsupportedPlatform => '此平台尚未支援'; } +// Path: shell +class Translations$shell$zh_TW { + Translations$shell$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '搜尋' + String get search => '搜尋'; + + /// zh-TW: '設定' + String get settings => '設定'; + + /// zh-TW: '搜尋(Ctrl+F)' + String get searchTooltip => '搜尋(Ctrl+F)'; + + /// zh-TW: '設定(Ctrl+,)' + String get settingsTooltip => '設定(Ctrl+,)'; +} + +// Path: search +class Translations$search$zh_TW { + Translations$search$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '搜尋歌曲' + String get hint => '搜尋歌曲'; + + /// zh-TW: '清除' + String get clear => '清除'; + + /// zh-TW: '正在載入音源' + String get loadingSources => '正在載入音源'; + + /// zh-TW: '沒有可以搜尋的音源' + String get noSources => '沒有可以搜尋的音源'; + + /// zh-TW: '安裝支援搜尋的插件後,就能在這裡搜尋' + String get noSourcesHint => '安裝支援搜尋的插件後,就能在這裡搜尋'; + + /// zh-TW: '輸入關鍵字開始搜尋' + String get prompt => '輸入關鍵字開始搜尋'; + + /// zh-TW: '搜尋中' + String get loading => '搜尋中'; + + /// zh-TW: '找不到「{keyword}」的結果' + String noResults({required Object keyword}) => '找不到「${keyword}」的結果'; + + /// zh-TW: '搜尋失敗' + String get failed => '搜尋失敗'; + + /// zh-TW: '重試' + String get retry => '重試'; + + /// zh-TW: '載入更多' + String get loadMore => '載入更多'; +} + +// Path: settings +class Translations$settings$zh_TW { + Translations$settings$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '設定' + String get title => '設定'; + + /// zh-TW: '外觀' + String get appearance => '外觀'; +} + +// Path: player +class Translations$player$zh_TW { + Translations$player$zh_TW.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '播放' + String get play => '播放'; + + /// zh-TW: '暫停' + String get pause => '暫停'; + + /// zh-TW: '上一首' + String get previous => '上一首'; + + /// zh-TW: '下一首' + String get next => '下一首'; + + /// zh-TW: '播放(空白鍵)' + String get playTooltip => '播放(空白鍵)'; + + /// zh-TW: '暫停(空白鍵)' + String get pauseTooltip => '暫停(空白鍵)'; + + /// zh-TW: '上一首(Ctrl+←)' + String get previousTooltip => '上一首(Ctrl+←)'; + + /// zh-TW: '下一首(Ctrl+→)' + String get nextTooltip => '下一首(Ctrl+→)'; + + /// zh-TW: '播放進度' + String get progress => '播放進度'; + + /// zh-TW: '載入中' + String get loading => '載入中'; +} + // Path: appearance class Translations$appearance$zh_TW { Translations$appearance$zh_TW.internal(this._root); diff --git a/app/lib/i18n/zh-CN.i18n.json b/app/lib/i18n/zh-CN.i18n.json index 31e3dec9e..098813f75 100644 --- a/app/lib/i18n/zh-CN.i18n.json +++ b/app/lib/i18n/zh-CN.i18n.json @@ -3,6 +3,41 @@ "databaseError": "无法打开数据库", "unsupportedPlatform": "暂不支持此平台" }, + "shell": { + "search": "搜索", + "settings": "设置", + "searchTooltip": "搜索(Ctrl+F)", + "settingsTooltip": "设置(Ctrl+,)" + }, + "search": { + "hint": "搜索歌曲", + "clear": "清除", + "loadingSources": "正在加载音源", + "noSources": "没有可以搜索的音源", + "noSourcesHint": "安装支持搜索的插件后,就能在这里搜索", + "prompt": "输入关键词开始搜索", + "loading": "正在搜索", + "noResults": "找不到“{keyword}”的结果", + "failed": "搜索失败", + "retry": "重试", + "loadMore": "加载更多" + }, + "settings": { + "title": "设置", + "appearance": "外观" + }, + "player": { + "play": "播放", + "pause": "暂停", + "previous": "上一首", + "next": "下一首", + "playTooltip": "播放(空格键)", + "pauseTooltip": "暂停(空格键)", + "previousTooltip": "上一首(Ctrl+←)", + "nextTooltip": "下一首(Ctrl+→)", + "progress": "播放进度", + "loading": "正在加载" + }, "appearance": { "theme": "主题", "themeSystem": "跟随系统", diff --git a/app/lib/i18n/zh-TW.i18n.json b/app/lib/i18n/zh-TW.i18n.json index 8e0b8dc38..701df9748 100644 --- a/app/lib/i18n/zh-TW.i18n.json +++ b/app/lib/i18n/zh-TW.i18n.json @@ -3,6 +3,41 @@ "databaseError": "無法開啟資料庫", "unsupportedPlatform": "此平台尚未支援" }, + "shell": { + "search": "搜尋", + "settings": "設定", + "searchTooltip": "搜尋(Ctrl+F)", + "settingsTooltip": "設定(Ctrl+,)" + }, + "search": { + "hint": "搜尋歌曲", + "clear": "清除", + "loadingSources": "正在載入音源", + "noSources": "沒有可以搜尋的音源", + "noSourcesHint": "安裝支援搜尋的插件後,就能在這裡搜尋", + "prompt": "輸入關鍵字開始搜尋", + "loading": "搜尋中", + "noResults": "找不到「{keyword}」的結果", + "failed": "搜尋失敗", + "retry": "重試", + "loadMore": "載入更多" + }, + "settings": { + "title": "設定", + "appearance": "外觀" + }, + "player": { + "play": "播放", + "pause": "暫停", + "previous": "上一首", + "next": "下一首", + "playTooltip": "播放(空白鍵)", + "pauseTooltip": "暫停(空白鍵)", + "previousTooltip": "上一首(Ctrl+←)", + "nextTooltip": "下一首(Ctrl+→)", + "progress": "播放進度", + "loading": "載入中" + }, "appearance": { "theme": "主題", "themeSystem": "跟隨系統", diff --git a/app/lib/main.dart b/app/lib/main.dart index deb41c7f6..73210bc1b 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -21,7 +21,6 @@ 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 { @@ -39,11 +38,14 @@ Future main(List arguments) async { : LogFile(Directory(p.join(directory.path, logDirectoryName))), ); routeUncaughtErrors(log, PlatformDispatcher.instance); + // 實機驗證以這一筆確認是哪個 flavor、資料在哪(沒有身分頁了)。資料目錄的 + // 路徑可能含使用者名稱:log 檔只在本機,診斷包(M3)匯出時另外處理。 log.info( 'App started', tag: 'app', fields: { 'flavor': flavor.name, + 'dataDirectory': ?directory?.path, 'buildMode': kReleaseMode ? 'release' : kProfileMode @@ -92,10 +94,6 @@ Future main(List arguments) async { 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/playback/dev_playback_entry.dart b/app/lib/playback/dev_playback_entry.dart deleted file mode 100644 index 12e6d965f..000000000 --- a/app/lib/playback/dev_playback_entry.dart +++ /dev/null @@ -1,86 +0,0 @@ -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_providers.dart b/app/lib/playback/playback_providers.dart index 6f4223a75..4d050332c 100644 --- a/app/lib/playback/playback_providers.dart +++ b/app/lib/playback/playback_providers.dart @@ -58,3 +58,9 @@ final playbackQueueProvider = StreamProvider((ref) async* { yield controller.queue; yield* controller.queueStates; }); + +/// 目前這首的位置、時長與緩衝(高頻,ADR 0018 §決定 2)。後端第一次回報前 +/// 還沒有值。 +final playbackProgressProvider = StreamProvider( + (ref) => ref.watch(playbackControllerProvider).progress, +); diff --git a/app/lib/ui/artwork/artwork_image.dart b/app/lib/ui/artwork/artwork_image.dart new file mode 100644 index 000000000..09cb5e599 --- /dev/null +++ b/app/lib/ui/artwork/artwork_image.dart @@ -0,0 +1,91 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 從插件給的多尺寸封面挑一張(ADR 0016 §決定 4):寬度不小於 [pixels] 的 +/// 裡面最小的;都比它小時,先取沒標寬度的(插件多半只給原圖,例如 B 站), +/// 再來才是最大的那張。沒有封面就是 `null`。 +Artwork? pickArtwork(List artwork, double pixels) { + Artwork? smallestEnough; + Artwork? largest; + Artwork? unknown; + for (final candidate in artwork) { + final width = candidate.width; + if (width == null) { + unknown ??= candidate; + } else if (width >= pixels) { + if (smallestEnough == null || width < smallestEnough.width!) { + smallestEnough = candidate; + } + } else if (largest == null || width > largest.width!) { + largest = candidate; + } + } + return smallestEnough ?? unknown ?? largest; +} + +/// 方形的封面縮圖;沒有封面、載入中與載入失敗時是同一個佔位圖。 +/// +/// 網址已在 DTO 解碼時經插件的 `allowedHosts` 檢查(`Artwork`),這裡直接以 +/// `Image.network` 讀,不帶 header:B 站的 hdslb 不帶 `Referer` 可以讀,帶了 +/// 別的網域反而被擋(2026-09-30 實測)。只有 Flutter 記憶體裡的 `ImageCache`; +/// 磁碟快取與經媒體 client 讀圖在 M6(ADR 0016 §決定 4、ADR 0012)。 +class ArtworkImage extends StatelessWidget { + const ArtworkImage({super.key, required this.artwork, required this.size}); + + final List artwork; + + /// 邊長(dp)。 + final double size; + + @override + Widget build(BuildContext context) { + final pixels = size * MediaQuery.devicePixelRatioOf(context); + final chosen = pickArtwork(artwork, pixels); + final placeholder = _Placeholder(size: size); + return ClipRRect( + borderRadius: BorderRadius.circular(AppTokens.of(context).radius.small), + child: SizedBox.square( + dimension: size, + child: chosen == null + ? placeholder + : Image.network( + chosen.url.toString(), + width: size, + height: size, + fit: BoxFit.cover, + // 以高解碼:封面多半是橫的(影片封面 16:9),裁成方形時高是 + // 短邊;以寬解碼會讓高不夠、放大後模糊。 + cacheHeight: pixels.round(), + // 封面是裝飾,曲名在旁邊。 + excludeFromSemantics: true, + frameBuilder: (context, child, frame, synchronous) => + frame == null && !synchronous ? placeholder : child, + errorBuilder: (context, error, stackTrace) => placeholder, + ), + ), + ); + } +} + +class _Placeholder extends StatelessWidget { + const _Placeholder({required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ColoredBox( + color: scheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.music_note, + size: size / 2, + color: scheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/app/lib/ui/format/duration_text.dart b/app/lib/ui/format/duration_text.dart new file mode 100644 index 000000000..77df0d7e7 --- /dev/null +++ b/app/lib/ui/format/duration_text.dart @@ -0,0 +1,12 @@ +/// 曲目時長與播放位置的文字:不到一小時是 `m:ss`,否則 `h:mm:ss`。 +/// +/// 不經翻譯:三種介面語言的播放器都寫成這樣(CLDR 沒有「時長」的格式)。 +String formatDuration(Duration duration) { + final totalSeconds = duration.isNegative ? 0 : duration.inSeconds; + final hours = totalSeconds ~/ 3600; + final minutes = totalSeconds ~/ 60 % 60; + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return hours > 0 + ? '$hours:${minutes.toString().padLeft(2, '0')}:$seconds' + : '$minutes:$seconds'; +} diff --git a/app/lib/ui/player/player_bar.dart b/app/lib/ui/player/player_bar.dart new file mode 100644 index 000000000..6c9959baf --- /dev/null +++ b/app/lib/ui/player/player_bar.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/ui/artwork/artwork_image.dart'; +import 'package:fmp/ui/format/duration_text.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/player/queue_tracks.dart'; +import 'package:fmp/ui/theme/app_layout.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 播放列(ADR 0024 §決定 5):封面、曲名、上傳者、播放控制與可拖動的進度條。 +/// 佇列是空的時候不佔位置。 +/// +/// 控制項依它所在的寬度(最近的 `WindowClassScope`)分三段,只放 M1 有的功能: +/// +/// - compact(< 600):播放、下一首; +/// - medium(600–839):上一首、播放、下一首(ADR 的音量與「⋯」在 M2); +/// - expanded 以上:同 medium,控制與進度條置中,右側留給 M2 的音量等控制項。 +/// +/// 曲名至少約 160dp(ADR 0024 §決定 5)。狀態都來自 `PlaybackController`; +/// 點空白處開播放頁是 M2。 +class PlayerBar extends ConsumerWidget { + const PlayerBar({super.key}); + + /// 曲名與上傳者那一欄;測試以它量曲名的寬度。 + static const titleKey = ValueKey('player-bar-title'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(playbackQueueProvider).value; + final current = queue?.current; + if (queue == null || current == null) return const SizedBox.shrink(); + final info = + ref.watch(queueTracksProvider)[TrackKey.format( + current.sourceTypeId, + current.sourceId, + cid: current.cid, + )]; + final state = ref.watch(playbackStateProvider).value ?? const Idle(); + final t = ref.watch(translationsProvider).player; + final theme = Theme.of(context); + final spacing = AppTokens.of(context).spacing; + + final track = Row( + children: [ + ArtworkImage( + artwork: info?.artwork ?? const [], + size: AppLayout.artworkThumbnail, + ), + SizedBox(width: spacing.x3), + Expanded( + key: titleKey, + child: _TrackText( + // 沒有顯示資料時(不經搜尋頁開始的播放)至少顯示音源內的 id。 + title: info?.title ?? current.sourceId, + uploader: info?.uploader, + ), + ), + ], + ); + final previous = IconButton( + tooltip: t.previousTooltip, + icon: Icon(Icons.skip_previous, semanticLabel: t.previous), + // 第一首時回到這首開頭,所以一直可以按。 + onPressed: () => + unawaited(ref.read(playbackControllerProvider).previous()), + ); + final next = IconButton( + tooltip: t.nextTooltip, + icon: Icon(Icons.skip_next, semanticLabel: t.next), + onPressed: queue.hasNext + ? () => unawaited(ref.read(playbackControllerProvider).next()) + : null, + ); + final playPause = _PlayPauseButton(state: state); + const progress = _ProgressRow(); + + return Material( + color: theme.colorScheme.surfaceContainer, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.x4, + vertical: spacing.x2, + ), + child: switch (WindowClass.of(context)) { + WindowClass.compact => Column( + mainAxisSize: MainAxisSize.min, + children: [ + progress, + Row( + children: [ + Expanded(child: track), + playPause, + next, + ], + ), + ], + ), + WindowClass.medium => Column( + mainAxisSize: MainAxisSize.min, + children: [ + progress, + Row( + children: [ + Expanded(child: track), + previous, + playPause, + next, + ], + ), + ], + ), + // 3:4:3 讓 840 寬時曲名仍有約 180dp。 + WindowClass.expanded || + WindowClass.large || + WindowClass.extraLarge => Row( + children: [ + Expanded(flex: 3, child: track), + Expanded( + flex: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [previous, playPause, next], + ), + progress, + ], + ), + ), + const Spacer(flex: 3), + ], + ), + }, + ), + ); + } +} + +class _TrackText extends StatelessWidget { + const _TrackText({required this.title, required this.uploader}); + + final String title; + final String? uploader; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final uploader = this.uploader; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + if (uploader != null) + Text( + uploader, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } +} + +/// 播放/暫停。載入、緩衝與等重試時在按鈕裡轉圈,按下是暫停(使用者要的是 +/// 「別播了」)。 +class _PlayPauseButton extends ConsumerWidget { + const _PlayPauseButton({required this.state}); + + final PlaybackState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider).player; + final busy = switch (state) { + Loading() || Buffering() || Retrying() => true, + Idle() || Playing() || Paused() || Failed() => false, + }; + final wantsSound = busy || state is Playing; + return IconButton.filled( + tooltip: wantsSound ? t.pauseTooltip : t.playTooltip, + onPressed: () { + final controller = ref.read(playbackControllerProvider); + unawaited(wantsSound ? controller.pause() : controller.play()); + }, + icon: busy + ? SizedBox.square( + dimension: IconTheme.of(context).size, + child: CircularProgressIndicator( + semanticsLabel: t.loading, + color: Theme.of(context).colorScheme.onPrimary, + ), + ) + : Icon( + wantsSound ? Icons.pause : Icons.play_arrow, + semanticLabel: wantsSound ? t.pause : t.play, + ), + ); + } +} + +/// 位置、進度條、時長。拖動時只改畫面上的位置,放開才 seek。 +class _ProgressRow extends ConsumerStatefulWidget { + const _ProgressRow(); + + @override + ConsumerState<_ProgressRow> createState() => _ProgressRowState(); +} + +class _ProgressRowState extends ConsumerState<_ProgressRow> { + /// 拖動中的位置(毫秒);沒在拖是 `null`。 + double? _dragging; + + @override + Widget build(BuildContext context) { + final t = ref.watch(translationsProvider).player; + final theme = Theme.of(context); + final progress = ref.watch(playbackProgressProvider).value; + final duration = progress?.duration; + final max = duration?.inMilliseconds.toDouble() ?? 0; + final seekable = max > 0; + final position = seekable + ? (_dragging ?? progress!.position.inMilliseconds.toDouble()).clamp( + 0.0, + max, + ) + : 0.0; + final timeStyle = theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + Duration at(double milliseconds) => + Duration(milliseconds: milliseconds.round()); + return Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: AppLayout.playerTimeLabel, + ), + child: Text( + formatDuration(at(position)), + style: timeStyle, + textAlign: TextAlign.end, + ), + ), + Expanded( + child: Semantics( + label: t.progress, + child: Slider( + value: position, + max: seekable ? max : 1, + semanticFormatterCallback: (value) => formatDuration(at(value)), + onChanged: seekable + ? (value) => setState(() => _dragging = value) + : null, + onChangeEnd: seekable + ? (value) { + setState(() => _dragging = null); + unawaited( + ref.read(playbackControllerProvider).seek(at(value)), + ); + } + : null, + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: AppLayout.playerTimeLabel, + ), + child: Text( + duration == null ? '-:--' : formatDuration(duration), + style: timeStyle, + ), + ), + ], + ); + } +} diff --git a/app/lib/ui/player/queue_tracks.dart b/app/lib/ui/player/queue_tracks.dart new file mode 100644 index 000000000..c697299dc --- /dev/null +++ b/app/lib/ui/player/queue_tracks.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/plugins/source_dto.dart'; + +/// 佇列裡每首曲目的顯示資料(曲名、上傳者、封面),鍵是 [trackKeyOf]。 +/// +/// M1 的佇列只有曲目鍵(ADR 0018 §決定 4),曲目的資料表在 M2 的音樂庫才有; +/// 在那之前,開始播放的畫面把手上的 [TrackSummary] 放在這裡,播放列以鍵查。 +/// 每次播放一份新的清單就整份換掉,不會一直長大。 +final queueTracksProvider = + NotifierProvider>(QueueTracks.new); + +final class QueueTracks extends Notifier> { + @override + Map build() => const {}; + + void replace(List tracks) => state = Map.unmodifiable({ + for (final track in tracks) trackKeyOf(track): track, + }); +} + +/// [track] 的曲目鍵(`TrackKey.format`)。 +String trackKeyOf(TrackSummary track) => + TrackKey.format(track.sourceTypeId, track.sourceId, cid: track.cid); + +/// 把整份 [tracks] 交給 `PlaybackController`(UI 唯一的播放入口),從第 +/// [index] 首開始依序播放。 +Future playTracks(WidgetRef ref, List tracks, int index) { + ref.read(queueTracksProvider.notifier).replace(tracks); + return ref.read(playbackControllerProvider).playQueue([ + for (final track in tracks) + TrackKeyParts( + sourceTypeId: track.sourceTypeId, + sourceId: track.sourceId, + cid: track.cid, + ), + ], startIndex: index); +} diff --git a/app/lib/ui/search/search_page.dart b/app/lib/ui/search/search_page.dart new file mode 100644 index 000000000..ac4efdb63 --- /dev/null +++ b/app/lib/ui/search/search_page.dart @@ -0,0 +1,291 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/ui/artwork/artwork_image.dart'; +import 'package:fmp/ui/format/duration_text.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/player/queue_tracks.dart'; +import 'package:fmp/ui/search/search_state.dart'; +import 'package:fmp/ui/search/source_chips.dart'; +import 'package:fmp/ui/theme/app_layout.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 搜尋頁:輸入框、音源 chip 列、結果列表。點一首就把整份結果交給播放, +/// 從那一首開始依序播。 +class SearchPage extends ConsumerStatefulWidget { + const SearchPage({super.key, required this.fieldFocusNode}); + + /// 輸入框的焦點;外殼的 Ctrl+F 以它把焦點移到輸入框。 + final FocusNode fieldFocusNode; + + @override + ConsumerState createState() => _SearchPageState(); +} + +class _SearchPageState extends ConsumerState { + late final _text = TextEditingController( + text: ref.read(searchProvider).keyword, + ); + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _submit(String keyword) => + unawaited(ref.read(searchProvider.notifier).search(keyword)); + + @override + Widget build(BuildContext context) { + final t = ref.watch(translationsProvider).search; + final tokens = AppTokens.of(context); + final spacing = tokens.spacing; + final sources = ref.watch(searchSourcesProvider); + final search = ref.watch(searchProvider); + final list = sources.value ?? const []; + final selected = selectedSourceOf(search.sourceId, list); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + spacing.x4, + spacing.x4, + spacing.x4, + spacing.x2, + ), + // 用 TextField 而不是 M3 的 SearchBar:SearchBar 整條可點的那一層 + // 沒有語意名稱,裡面的輸入框只有文字那一行高(24dp),兩個都過不了 + // 點擊區 guideline。外觀照 SearchBar:填色、全圓角、前面放大鏡。 + child: TextField( + controller: _text, + focusNode: widget.fieldFocusNode, + textInputAction: TextInputAction.search, + onSubmitted: _submit, + decoration: InputDecoration( + hintText: t.hint, + prefixIcon: const Icon(Icons.search), + suffixIcon: ListenableBuilder( + listenable: _text, + builder: (context, _) => _text.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + tooltip: t.clear, + icon: const Icon(Icons.close), + onPressed: () { + _text.clear(); + widget.fieldFocusNode.requestFocus(); + }, + ), + ), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(tokens.radius.extraLarge), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (list.isNotEmpty) ...[ + SourceChips( + sources: list, + selectedId: selected?.manifest.id, + onSelected: (id) => + unawaited(ref.read(searchProvider.notifier).selectSource(id)), + ), + SizedBox(height: spacing.x2), + ], + Expanded( + child: switch (sources) { + AsyncData() when list.isEmpty => _Message( + icon: Icons.extension_off_outlined, + title: t.noSources, + body: t.noSourcesHint, + ), + AsyncData() => _Results(state: search, onRetry: _retry), + // 插件清單載入失敗時已經 log.report;畫面上等同沒有音源。 + AsyncError() => _Message( + icon: Icons.extension_off_outlined, + title: t.noSources, + body: t.noSourcesHint, + ), + AsyncLoading() => _Loading(label: t.loadingSources), + }, + ), + ], + ); + } + + void _retry() => _submit(ref.read(searchProvider).keyword); +} + +/// 結果區:依 [SearchPhase] 顯示提示、載入中、失敗、沒有結果或列表。 +class _Results extends ConsumerWidget { + const _Results({required this.state, required this.onRetry}); + + final SearchState state; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider).search; + return switch (state.phase) { + SearchPhase.idle => _Message(icon: Icons.search, title: t.prompt), + SearchPhase.loading => _Loading(label: t.loading), + SearchPhase.failed => _Message( + icon: Icons.error_outline, + title: t.failed, + action: FilledButton.tonal(onPressed: onRetry, child: Text(t.retry)), + ), + SearchPhase.loaded when state.items.isEmpty => _Message( + icon: Icons.search_off, + title: t.noResults(keyword: state.keyword), + ), + SearchPhase.loaded || + SearchPhase.loadingMore => _ResultList(state: state), + }; + } +} + +class _ResultList extends ConsumerWidget { + const _ResultList({required this.state}); + + final SearchState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider).search; + final spacing = AppTokens.of(context).spacing; + final items = state.items; + final footer = state.hasMore || state.phase == SearchPhase.loadingMore; + return ListView.builder( + padding: EdgeInsets.only(bottom: spacing.x4), + itemCount: items.length + (footer ? 1 : 0), + itemBuilder: (context, index) { + if (index < items.length) { + return _TrackTile( + track: items[index], + onTap: () => unawaited(playTracks(ref, items, index)), + ); + } + return Padding( + padding: EdgeInsets.all(spacing.x2), + child: Center( + child: state.phase == SearchPhase.loadingMore + ? _Spinner(label: t.loading) + : OutlinedButton( + onPressed: () => + unawaited(ref.read(searchProvider.notifier).loadMore()), + child: Text(t.loadMore), + ), + ), + ); + }, + ); + } +} + +/// 一首搜尋結果:封面、曲名、上傳者、時長。 +class _TrackTile extends StatelessWidget { + const _TrackTile({required this.track, required this.onTap}); + + final TrackSummary track; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final uploader = track.uploader; + final duration = track.duration; + return ListTile( + leading: ArtworkImage( + artwork: track.artwork, + size: AppLayout.artworkThumbnail, + ), + title: Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: uploader == null + ? null + : Text(uploader, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: duration == null ? null : Text(formatDuration(duration)), + onTap: onTap, + ); + } +} + +/// 置中的圖示與說明:空狀態與失敗。 +class _Message extends StatelessWidget { + const _Message({ + required this.icon, + required this.title, + this.body, + this.action, + }); + + final IconData icon; + final String title; + final String? body; + final Widget? action; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final spacing = AppTokens.of(context).spacing; + final body = this.body; + final action = this.action; + return Center( + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.x6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: AppLayout.emptyStateIcon, + color: theme.colorScheme.onSurfaceVariant, + ), + SizedBox(height: spacing.x4), + Text( + title, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + if (body != null) ...[ + SizedBox(height: spacing.x2), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[SizedBox(height: spacing.x4), action], + ], + ), + ), + ); + } +} + +class _Loading extends StatelessWidget { + const _Loading({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => Center(child: _Spinner(label: label)); +} + +/// 帶語意標籤的轉圈:螢幕閱讀器念得出在等什麼。 +class _Spinner extends StatelessWidget { + const _Spinner({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => + CircularProgressIndicator(semanticsLabel: label); +} diff --git a/app/lib/ui/search/search_state.dart b/app/lib/ui/search/search_state.dart new file mode 100644 index 000000000..428f63b8d --- /dev/null +++ b/app/lib/ui/search/search_state.dart @@ -0,0 +1,185 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/manifest/plugin_manifest.dart'; +import 'package:fmp/plugins/plugin_registry.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; +import 'package:fmp/ui/player/queue_tracks.dart'; +import 'package:fmp/ui/toast/toaster.dart'; + +/// 可以搜尋的音源:已載入、宣告了 `search`、沒有停用的插件,照插件清單的順序。 +final searchSourcesProvider = Provider>>( + (ref) => ref + .watch(pluginRegistryProvider) + .whenData( + (plugins) => [ + for (final plugin in plugins.values) + if (plugin.manifest.capabilities.contains( + PluginCapability.search, + ) && + plugin.health == PluginHealth.ready) + plugin, + ], + ), +); + +/// 搜尋頁在做什麼。 +enum SearchPhase { + /// 還沒搜尋。 + idle, + + /// 第一頁載入中。 + loading, + + /// 有結果(可能是 0 筆)。 + loaded, + + /// 下一頁載入中,已有的結果照樣顯示。 + loadingMore, + + /// 第一頁失敗;錯誤已經以提示顯示過。 + failed, +} + +/// 搜尋頁的狀態。 +@immutable +final class SearchState { + const SearchState({ + this.sourceId, + this.keyword = '', + this.items = const [], + this.hasMore = false, + this.page = 0, + this.phase = SearchPhase.idle, + }); + + /// 使用者選的音源(插件 id);`null` 或不在清單上時是清單的第一個。 + final String? sourceId; + + /// 送出的關鍵字(去掉前後空白)。 + final String keyword; + final List items; + final bool hasMore; + + /// 已載入到第幾頁;還沒載入是 0。 + final int page; + final SearchPhase phase; + + SearchState copyWith({ + String? sourceId, + List? items, + bool? hasMore, + int? page, + SearchPhase? phase, + }) => SearchState( + sourceId: sourceId ?? this.sourceId, + keyword: keyword, + items: items ?? this.items, + hasMore: hasMore ?? this.hasMore, + page: page ?? this.page, + phase: phase ?? this.phase, + ); +} + +/// [sources] 裡目前選的音源([SearchState.sourceId],不在清單上就是第一個)。 +SourcePlugin? selectedSourceOf(String? sourceId, List sources) { + for (final source in sources) { + if (source.manifest.id == sourceId) return source; + } + return sources.firstOrNull; +} + +/// 搜尋頁的狀態;換到設定頁再回來時保留。 +final searchProvider = NotifierProvider( + SearchNotifier.new, +); + +/// 搜尋與「載入更多」。新的搜尋開始後,之前還沒回來的結果丟掉。 +/// +/// 失敗經 [Toaster.error] 提示(使用者按了搜尋,屬於使用者動作的回饋)。 +final class SearchNotifier extends Notifier { + int _generation = 0; + + @override + SearchState build() => const SearchState(); + + /// 換音源;已經搜尋過就以同一個關鍵字重新搜尋。點的是目前選的(包括還沒 + /// 點過時預設的第一個)就不動。 + Future selectSource(String sourceId) async { + if (sourceId == _source()?.manifest.id) return; + state = SearchState(sourceId: sourceId, keyword: state.keyword); + if (state.keyword.isNotEmpty) await search(state.keyword); + } + + /// 搜尋 [keyword] 的第一頁;只有空白就不做。 + Future search(String keyword) async { + final trimmed = keyword.trim(); + final source = _source(); + if (trimmed.isEmpty || source == null) return; + final generation = ++_generation; + state = SearchState( + sourceId: state.sourceId, + keyword: trimmed, + phase: SearchPhase.loading, + ); + await _load(source, trimmed, page: 1, generation: generation); + } + + /// 下一頁,接在已有的結果之後。 + Future loadMore() async { + final source = _source(); + if (state.phase != SearchPhase.loaded || !state.hasMore || source == null) { + return; + } + final generation = ++_generation; + state = state.copyWith(phase: SearchPhase.loadingMore); + await _load( + source, + state.keyword, + page: state.page + 1, + generation: generation, + ); + } + + SourcePlugin? _source() => selectedSourceOf( + state.sourceId, + ref.read(searchSourcesProvider).value ?? const [], + ); + + Future _load( + SourcePlugin source, + String keyword, { + required int page, + required int generation, + }) async { + final SearchPage result; + try { + result = await source.search(SearchQuery(keyword: keyword, page: page)); + } on AppError catch (error) { + if (!ref.mounted || generation != _generation) return; + ref + .read(toasterProvider) + .error(error, operation: 'Search failed', tag: 'search'); + // 下一頁失敗時留著已有的結果,可以再按一次「載入更多」。 + state = state.copyWith( + phase: page == 1 ? SearchPhase.failed : SearchPhase.loaded, + ); + return; + } + if (!ref.mounted || generation != _generation) return; + // 兩次請求之間排序變了時,下一頁會有上一頁已經列出的曲目:只留第一次的, + // 同一首不會在列表與佇列裡出現兩次。 + final listed = {if (page > 1) ...state.items.map(trackKeyOf)}; + state = state.copyWith( + items: List.unmodifiable([ + if (page > 1) ...state.items, + ...result.items.where((track) => listed.add(trackKeyOf(track))), + ]), + hasMore: result.hasMore, + page: page, + phase: SearchPhase.loaded, + ); + } +} diff --git a/app/lib/ui/search/source_chips.dart b/app/lib/ui/search/source_chips.dart new file mode 100644 index 000000000..ad144ae42 --- /dev/null +++ b/app/lib/ui/search/source_chips.dart @@ -0,0 +1,106 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/plugins/source_plugin.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 音源的 chip 列(ADR 0024 §決定 6):單選、可橫向捲動,還有內容捲在外面的 +/// 那一端以漸層淡出,提示可以捲。 +class SourceChips extends StatefulWidget { + const SourceChips({ + super.key, + required this.sources, + required this.selectedId, + required this.onSelected, + }); + + final List sources; + final String? selectedId; + final ValueChanged onSelected; + + @override + State createState() => _SourceChipsState(); +} + +class _SourceChipsState extends State { + final _scroll = ScrollController(); + + /// 開頭/結尾還有內容在畫面外。 + bool _moreBefore = false; + bool _moreAfter = false; + + @override + void initState() { + super.initState(); + _scroll.addListener(_updateEdges); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _updateEdges() { + if (!_scroll.hasClients) return; + final position = _scroll.position; + final before = position.pixels > position.minScrollExtent; + final after = position.pixels < position.maxScrollExtent; + if (before != _moreBefore || after != _moreAfter) { + setState(() { + _moreBefore = before; + _moreAfter = after; + }); + } + } + + @override + Widget build(BuildContext context) { + final spacing = AppTokens.of(context).spacing; + final fade = spacing.x6; + // dstIn 只看 alpha:不透明的地方照畫,透明的地方挖掉。 + final opaque = Theme.of(context).colorScheme.onSurface; + final clear = opaque.withValues(alpha: 0); + return NotificationListener( + // 第一次排版與視窗改變寬度時,捲動範圍才確定。 + onNotification: (_) { + _updateEdges(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) { + final edge = bounds.width == 0 ? 0.0 : fade / bounds.width; + return LinearGradient( + colors: [ + if (_moreBefore) clear else opaque, + opaque, + opaque, + if (_moreAfter) clear else opaque, + ], + stops: [0, edge, 1 - edge, 1], + ).createShader(bounds); + }, + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: spacing.x4), + child: Row( + children: [ + for (final (index, source) in widget.sources.indexed) + Padding( + padding: EdgeInsetsDirectional.only( + start: index == 0 ? 0 : spacing.x2, + ), + child: ChoiceChip( + label: Text(source.manifest.name), + selected: source.manifest.id == widget.selectedId, + onSelected: (_) => widget.onSelected(source.manifest.id), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/settings/appearance_controls.dart b/app/lib/ui/settings/appearance_controls.dart index 09418ec01..7b66573f6 100644 --- a/app/lib/ui/settings/appearance_controls.dart +++ b/app/lib/ui/settings/appearance_controls.dart @@ -9,9 +9,7 @@ import 'package:fmp/ui/i18n/ui_locale.dart'; import 'package:fmp/ui/theme/app_tokens.dart'; /// 外觀設定的主題與語言(ADR 0011 §決定 7)。「跟隨系統」寫回 `null`(沒設定 -/// 過),不是存一個值。 -/// -/// M1 放在身分頁供實機切換;12b 的設定頁沿用。 +/// 過),不是存一個值。設定頁(`settings_page.dart`)的外觀組。 class AppearanceControls extends ConsumerWidget { const AppearanceControls({super.key}); @@ -29,7 +27,7 @@ class AppearanceControls extends ConsumerWidget { children: [ Text(t.theme, style: textTheme.titleSmall), SizedBox(height: spacing.x2), - // 舊資料或之後的設定頁可能存了明確的 system;兩者都顯示為跟隨系統, + // 舊資料可能存了明確的 system;兩者都顯示為跟隨系統, // 選它一律清回 null。 SegmentedButton( segments: [ diff --git a/app/lib/ui/settings/settings_page.dart b/app/lib/ui/settings/settings_page.dart new file mode 100644 index 000000000..b7eae1eb9 --- /dev/null +++ b/app/lib/ui/settings/settings_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/settings/appearance_controls.dart'; +import 'package:fmp/ui/theme/app_layout.dart'; +import 'package:fmp/ui/theme/app_tokens.dart'; + +/// 設定頁(ADR 0024 §決定 6、ADR 0011 §決定 7 的分組)。M1 只有外觀一組。 +/// +/// expanded 以上是 M3 canonical layout 的 list-detail:左邊分組清單、右邊那一組 +/// 的內容;更窄時只有一欄,各組的標題與內容依序排下來(只有一組時,點進去再 +/// 看內容只是多一步)。 +class SettingsPage extends ConsumerWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider).settings; + final theme = Theme.of(context); + final spacing = AppTokens.of(context).spacing; + final title = Padding( + padding: EdgeInsets.fromLTRB(spacing.x4, spacing.x4, spacing.x4, 0), + child: Semantics( + header: true, + child: Text(t.title, style: theme.textTheme.headlineSmall), + ), + ); + final groupTitle = Semantics( + header: true, + child: Text(t.appearance, style: theme.textTheme.titleLarge), + ); + final detail = SingleChildScrollView( + padding: EdgeInsets.all(spacing.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + groupTitle, + SizedBox(height: spacing.x4), + const AppearanceControls(), + ], + ), + ); + return switch (WindowClass.of(context)) { + WindowClass.compact || WindowClass.medium => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + title, + Expanded(child: detail), + ], + ), + WindowClass.expanded || + WindowClass.large || + WindowClass.extraLarge => Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: AppLayout.settingsListWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + title, + SizedBox(height: spacing.x2), + // 只有一組,永遠是選取的那一組;有第二組時才需要可以點。 + ListTile( + leading: const Icon(Icons.palette_outlined), + title: Text(t.appearance), + selected: true, + ), + ], + ), + ), + const VerticalDivider(), + Expanded(child: detail), + ], + ), + }; + } +} diff --git a/app/lib/ui/shell/app_shell.dart b/app/lib/ui/shell/app_shell.dart new file mode 100644 index 000000000..833b4cfef --- /dev/null +++ b/app/lib/ui/shell/app_shell.dart @@ -0,0 +1,401 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/player/player_bar.dart'; +import 'package:fmp/ui/search/search_page.dart'; +import 'package:fmp/ui/settings/settings_page.dart'; +import 'package:fmp/ui/shell/shell_shortcuts.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:fmp/ui/toast/toaster.dart'; + +/// 外殼的導覽項。 +enum ShellDestination { search, settings } + +/// App 的外殼(ADR 0024 §決定 3、5、8):導覽、內容、播放列三區。 +/// +/// 導覽依整個視窗的 `WindowClass` 換元件(M3 的 window size class 與導覽元件 +/// 對照;用 Material 內建的三個元件,ADR 否決了 `flutter_adaptive_scaffold`): +/// +/// - compact:底部 `NavigationBar`,播放列在它上面; +/// - medium、expanded:左側 `NavigationRail`; +/// - large 以上:左側常駐的 `NavigationDrawer`。 +/// +/// 後兩種的播放列在內容區下方、和內容區同寬(ADR 的「依內容區寬度」)。內容區 +/// 與播放列各自有 `WindowClassScope`,頁面讀到的是自己那一塊的寬度等級。 +/// +/// 三區各是一個 `FocusScope`:Tab 只在區內循環,F6 換區(`shell_shortcuts.dart`)。 +/// 底部被外殼佔住的高度(播放列、底部導覽列、安全區)量出來發佈給 +/// `toastBottomInsetProvider`,提示浮在它們上面(ADR 0023 §決定 2)。 +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + var _destination = ShellDestination.search; + + final _navigation = FocusScopeNode(debugLabel: 'Shell navigation'); + final _content = FocusScopeNode(debugLabel: 'Shell content'); + final _playerBar = FocusScopeNode(debugLabel: 'Shell player bar'); + final _searchField = FocusNode(debugLabel: 'Search field'); + + late final List _regions = [ + _navigation, + _content, + _playerBar, + ]; + + @override + void dispose() { + for (final node in [..._regions, _searchField]) { + node.dispose(); + } + super.dispose(); + } + + void _select(ShellDestination destination) { + if (destination != _destination) setState(() => _destination = destination); + } + + /// 換頁並在下一幀把焦點放進 [focus](`IndexedStack` 換頁後,新頁的焦點才 + /// 不再被排除)。已經在那一頁時馬上放:不換頁就沒有重建,閒著時不會有下一幀。 + void _selectAndFocus(ShellDestination destination, void Function() focus) { + if (destination == _destination) return focus(); + _select(destination); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) focus(); + }); + } + + // ---- 快捷鍵 --------------------------------------------------------------- + + void _playPause() { + final controller = ref.read(playbackControllerProvider); + switch (controller.state) { + case Playing() || Loading() || Buffering() || Retrying(): + unawaited(controller.pause()); + case Idle() || Paused() || Failed(): + unawaited(controller.play()); + } + } + + void _seekBy(Duration offset) { + final progress = ref.read(playbackProgressProvider).value; + if (progress == null) return; + var target = progress.position + offset; + if (target.isNegative) target = Duration.zero; + final duration = progress.duration; + if (duration != null && target > duration) target = duration; + unawaited(ref.read(playbackControllerProvider).seek(target)); + } + + /// F6:從焦點所在的區往下一區,跳過不在畫面上或沒有可聚焦項目的區。焦點 + /// 不在任何一區時從導覽開始。 + void _nextRegion() { + final focus = FocusManager.instance.primaryFocus; + final current = focus == null + ? -1 + : _regions.indexWhere( + (region) => focus == region || focus.ancestors.contains(region), + ); + for (var step = 1; step <= _regions.length; step++) { + final region = _regions[(current + step) % _regions.length]; + if (_focusInto(region)) return; + } + } + + /// 回到 [region] 上次的焦點,沒有就是它的第一個可聚焦項目(樹的順序,三區 + /// 都是由上而下、由左而右排)。 + bool _focusInto(FocusScopeNode region) { + if (region.context == null) return false; + final previous = region.focusedChild; + final target = previous != null && previous.canRequestFocus + ? previous + : region.traversalDescendants.firstOrNull; + if (target == null) return false; + target.requestFocus(); + return true; + } + + // ---- 播放失敗的提示 --------------------------------------------------------- + + /// 播放停在 `Failed`(連續跳過到上限或最後一首也播不了)時提示一次。在 + /// listener 裡呼叫,不在 build 裡:`Toaster` 同步送出,`ToastHost` 會馬上 + /// `showSnackBar`。 + void _onPlaybackState( + AsyncValue? previous, + AsyncValue next, + ) { + if (next.value case Failed(:final error) + when !identical(previous?.value, next.value)) { + ref + .read(toasterProvider) + .error(error, operation: 'Playback stopped', tag: 'playback'); + } + } + + @override + Widget build(BuildContext context) { + ref.listen(playbackStateProvider, _onPlaybackState); + final t = ref.watch(translationsProvider).shell; + final hasTrack = ref.watch( + playbackQueueProvider.select((queue) => queue.value?.current != null), + ); + + final content = FocusScope( + node: _content, + child: FocusTraversalGroup( + child: WindowClassScope( + child: IndexedStack( + index: _destination.index, + sizing: StackFit.expand, + children: [ + SearchPage(fieldFocusNode: _searchField), + const SettingsPage(), + ], + ), + ), + ), + ); + final playerBar = hasTrack + ? FocusScope( + node: _playerBar, + child: FocusTraversalGroup( + child: const WindowClassScope(child: PlayerBar()), + ), + ) + : null; + Widget navigation(Widget child) => FocusScope( + node: _navigation, + child: FocusTraversalGroup(child: child), + ); + void onSelected(int index) => _select(ShellDestination.values[index]); + final index = _destination.index; + + final body = switch (WindowClass.of(context)) { + WindowClass.compact => Scaffold( + body: SafeArea(bottom: false, child: content), + bottomNavigationBar: _BottomInsetReporter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?playerBar, + navigation( + NavigationBar( + selectedIndex: index, + onDestinationSelected: onSelected, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.search_outlined), + selectedIcon: const Icon(Icons.search), + label: t.search, + tooltip: t.searchTooltip, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: t.settings, + tooltip: t.settingsTooltip, + ), + ], + ), + ), + ], + ), + ), + ), + final windowClass => Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + navigation(switch (windowClass) { + WindowClass.medium || WindowClass.expanded => SafeArea( + right: false, + child: NavigationRail( + selectedIndex: index, + onDestinationSelected: onSelected, + labelType: NavigationRailLabelType.all, + destinations: [ + NavigationRailDestination( + icon: const Icon(Icons.search_outlined), + selectedIcon: const Icon(Icons.search), + label: Text(t.search), + ), + NavigationRailDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: Text(t.settings), + ), + ], + ), + ), + _ => _PermanentDrawer( + selectedIndex: index, + onSelected: onSelected, + search: t.search, + settings: t.settings, + ), + }), + Expanded( + child: Column( + children: [ + Expanded( + child: SafeArea( + left: false, + bottom: playerBar == null, + child: content, + ), + ), + _BottomInsetReporter( + child: playerBar == null + ? const SizedBox.shrink() + : SafeArea(top: false, left: false, child: playerBar), + ), + ], + ), + ), + ], + ), + ), + }; + + return Shortcuts( + shortcuts: shellShortcuts, + child: Actions( + actions: { + PlayPauseIntent: TextInputAwareAction( + onInvoke: (_) => _playPause(), + ), + PreviousTrackIntent: TextInputAwareAction( + onInvoke: (_) => + unawaited(ref.read(playbackControllerProvider).previous()), + ), + NextTrackIntent: TextInputAwareAction( + onInvoke: (_) => + unawaited(ref.read(playbackControllerProvider).next()), + ), + SeekByIntent: TextInputAwareAction( + onInvoke: (intent) => _seekBy(intent.offset), + ), + FocusSearchIntent: CallbackAction( + onInvoke: (_) => _selectAndFocus( + ShellDestination.search, + _searchField.requestFocus, + ), + ), + OpenSettingsIntent: CallbackAction( + onInvoke: (_) => _selectAndFocus( + ShellDestination.settings, + () => _focusInto(_content), + ), + ), + NextRegionIntent: CallbackAction( + onInvoke: (_) => _nextRegion(), + ), + }, + // 一開始就有焦點在外殼裡,快捷鍵才收得到按鍵;它不在 Tab 的順序裡。 + child: Focus(autofocus: true, skipTraversal: true, child: body), + ), + ); + } +} + +/// large 以上的常駐導覽抽屜。M3 的 standard drawer:和內容並排、沒有遮罩, +/// 所以不要 modal drawer 的圓角與陰影。 +class _PermanentDrawer extends StatelessWidget { + const _PermanentDrawer({ + required this.selectedIndex, + required this.onSelected, + required this.search, + required this.settings, + }); + + final int selectedIndex; + final ValueChanged onSelected; + final String search; + final String settings; + + @override + Widget build(BuildContext context) => DrawerTheme( + data: DrawerTheme.of(context) + .copyWith(shape: const RoundedRectangleBorder()), + child: NavigationDrawer( + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + elevation: 0, + children: [ + NavigationDrawerDestination( + icon: const Icon(Icons.search_outlined), + selectedIcon: const Icon(Icons.search), + label: Text(search), + ), + NavigationDrawerDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: Text(settings), + ), + ], + ), + ); +} + +/// 量 [child] 的高度,發佈成 `toastBottomInsetProvider`:[child] 貼著視窗 +/// 底邊,所以它的高度就是從底邊算起被佔住的高度(含它自己處理的安全區)。 +class _BottomInsetReporter extends ConsumerWidget { + const _BottomInsetReporter({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) => _HeightReporter( + // 在排版之後的那一幀結束才寫:排版與 build 中不能改 provider。 + onHeight: (height) => SchedulerBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + ref.read(toastBottomInsetProvider.notifier).set(height); + } + }), + child: child, + ); +} + +class _HeightReporter extends SingleChildRenderObjectWidget { + const _HeightReporter({required this.onHeight, super.child}); + + final ValueChanged onHeight; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderHeightReporter(onHeight); + + @override + void updateRenderObject( + BuildContext context, + _RenderHeightReporter renderObject, + ) => renderObject.onHeight = onHeight; +} + +class _RenderHeightReporter extends RenderProxyBox { + _RenderHeightReporter(this.onHeight); + + ValueChanged onHeight; + double? _reported; + + @override + void performLayout() { + super.performLayout(); + if (size.height != _reported) { + _reported = size.height; + onHeight(size.height); + } + } +} diff --git a/app/lib/ui/shell/shell_shortcuts.dart b/app/lib/ui/shell/shell_shortcuts.dart new file mode 100644 index 000000000..8fa38cb21 --- /dev/null +++ b/app/lib/ui/shell/shell_shortcuts.dart @@ -0,0 +1,87 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +// App 內快捷鍵(ADR 0024 §決定 8):固定、不可自訂,M1 只有已有功能的那幾個。 +// +// 綁在外殼的 `Shortcuts` 上,所以只在 FMP 在前景、焦點在外殼裡時有效(對話框 +// 開著時焦點在對話框的路由裡,這些鍵不作用)。 +// +// 焦點在輸入框時,文字編輯也用到的鍵(空白鍵、Ctrl/Shift 加方向鍵)要讓給 +// 輸入框:輸入框的 `DefaultTextEditingShortcuts` 由 `WidgetsApp` 放在 App 根, +// 比外殼的 `Shortcuts` 還遠,按鍵會先被外殼接走。所以這幾個鍵的 action 在 +// [focusInTextInput] 時停用([TextInputAwareAction]),`Shortcuts` 就不處理, +// 按鍵往上交給文字編輯(輸入空格、以字移動、延伸選取)。Ctrl+F、Ctrl+, 與 +// F6 不是文字編輯鍵,在輸入框裡也有效。 + +/// 播放與暫停。 +final class PlayPauseIntent extends Intent { + const PlayPauseIntent(); +} + +final class PreviousTrackIntent extends Intent { + const PreviousTrackIntent(); +} + +final class NextTrackIntent extends Intent { + const NextTrackIntent(); +} + +/// 從目前位置前後移動 [offset]。 +final class SeekByIntent extends Intent { + const SeekByIntent(this.offset); + + final Duration offset; +} + +/// 到搜尋頁,焦點放進輸入框。 +final class FocusSearchIntent extends Intent { + const FocusSearchIntent(); +} + +/// 到設定頁。 +final class OpenSettingsIntent extends Intent { + const OpenSettingsIntent(); +} + +/// 焦點移到下一區(導覽 → 內容 → 播放列 → 導覽)。 +final class NextRegionIntent extends Intent { + const NextRegionIntent(); +} + +/// 焦點在輸入框(`EditableText`)裡。 +bool focusInTextInput() { + final context = FocusManager.instance.primaryFocus?.context; + return context != null && + (context.widget is EditableText || + context.findAncestorWidgetOfExactType() != null); +} + +/// 焦點在輸入框時停用的 action:給同時是文字編輯鍵的快捷鍵用。 +final class TextInputAwareAction extends CallbackAction { + TextInputAwareAction({required super.onInvoke}); + + @override + bool isEnabled(T intent) => !focusInTextInput(); +} + +/// Shift+←/→ 一次移動幾秒。 +const seekStepSeconds = 5; + +/// 外殼的快捷鍵表。按鍵也寫在提示文字裡(翻譯檔的 `*Tooltip`),改這裡要一起改。 +const shellShortcuts = { + SingleActivator(LogicalKeyboardKey.space): PlayPauseIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): + PreviousTrackIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight, control: true): + NextTrackIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): SeekByIntent( + Duration(seconds: -seekStepSeconds), + ), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): SeekByIntent( + Duration(seconds: seekStepSeconds), + ), + SingleActivator(LogicalKeyboardKey.keyF, control: true): FocusSearchIntent(), + SingleActivator(LogicalKeyboardKey.comma, control: true): + OpenSettingsIntent(), + SingleActivator(LogicalKeyboardKey.f6): NextRegionIntent(), +}; diff --git a/app/lib/ui/theme/app_layout.dart b/app/lib/ui/theme/app_layout.dart index cfec9ae3c..67e558a6d 100644 --- a/app/lib/ui/theme/app_layout.dart +++ b/app/lib/ui/theme/app_layout.dart @@ -3,4 +3,16 @@ abstract final class AppLayout { /// 桌面上提示(Toast)的最大寬度(ADR 0023 §決定 2)。 static const double toastMaxWidth = 560; + + /// 列表與播放列上的小封面(M3 list item 的 leading 圖片尺寸)。 + static const double artworkThumbnail = 48; + + /// 空狀態畫面的圖示。 + static const double emptyStateIcon = 48; + + /// 設定頁 list-detail 版面(expanded 以上)左側分組清單的寬度。 + static const double settingsListWidth = 280; + + /// 播放列上時間文字的最小寬度:位置在播放中變長變短時,進度條不跟著左右跳。 + static const double playerTimeLabel = 48; } diff --git a/app/lib/ui/toast/toast_host.dart b/app/lib/ui/toast/toast_host.dart index 4a2082be4..d7703ba20 100644 --- a/app/lib/ui/toast/toast_host.dart +++ b/app/lib/ui/toast/toast_host.dart @@ -12,7 +12,7 @@ import 'package:fmp/ui/toast/toaster.dart'; /// 視窗底部被外殼佔住的高度(dp,從視窗底邊算起,含系統的安全區),例如手機 /// 的迷你播放列加底部導覽列、桌面的播放列(ADR 0023 §決定 2)。 /// -/// 外殼在自己的版面改變時寫入;沒有外殼(全螢幕頁、M1 的身分頁)時是 0,提示 +/// 外殼(`AppShell`)在自己的版面改變時寫入;沒有外殼時(全螢幕頁)是 0,提示 /// 貼著底部安全區。 final toastBottomInsetProvider = NotifierProvider( ToastBottomInset.new, diff --git a/app/pubspec.lock b/app/pubspec.lock index 4fcfcd6ae..ae615a17e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "103.0.0" + alchemist: + dependency: "direct dev" + description: + name: alchemist + sha256: c7d8c7a1116cbfa22286e8ff45d37efe2c31fc63a82ed06c7ccebc997159c21f + url: "https://pub.dev" + source: hosted + version: "0.14.0" analysis_server_plugin: dependency: transitive description: @@ -289,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.35.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: "direct dev" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 497c194ff..1afc8067b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: talker: ^5.1.20 dev_dependencies: + # 播放列的 golden(ADR 0024 §如何確認)。只用它的 CI 版(文字畫成色塊), + # 設定在 test/flutter_test_config.dart。 + alchemist: ^0.14.0 # 只給 test/core/errors/app_error_surface_test.dart 解析原始碼(parseString)。 # 版本由 packages/fmp_lints 的釘版決定(同一個 workspace 只解出一份)。 analyzer: any diff --git a/app/test/app/fmp_app_test.dart b/app/test/app/fmp_app_test.dart index 5ca59b087..3fd1b8308 100644 --- a/app/test/app/fmp_app_test.dart +++ b/app/test/app/fmp_app_test.dart @@ -15,6 +15,7 @@ import 'package:fmp/domain/appearance.dart'; import 'package:fmp/platform/app_data_directory/app_data_directory.dart'; import 'package:fmp/platform/fonts/fonts.dart'; import 'package:fmp/platform/platform_capabilities.dart'; +import 'package:fmp/ui/shell/app_shell.dart'; import 'package:material_ui/material_ui.dart'; import '../plugins/plugin_harness.dart'; @@ -23,7 +24,7 @@ import '../support/memory_database.dart'; void main() { final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; - /// 以身分頁啟動 App;回傳它的 log,讀記憶體歷史用。 + /// 啟動 App(外殼);回傳它的 log,讀記憶體歷史用。 Future pumpApp( WidgetTester tester, { AppDatabase? database, @@ -54,7 +55,7 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('shows the app name, flavor, data directory and plugins', ( + testWidgets('opens on search; installed plugins are the sources', ( tester, ) async { final database = memoryDatabase(); @@ -72,19 +73,27 @@ void main() { await pumpApp(tester, database: database); // 插件在背景 isolate 載入:spawn 與 port 的訊息要真的事件迴圈,所以在 - // runAsync 裡讓它跑,直到清單出現。 - final plugin = find.text('fmp-test 1.0.0'); - for (var round = 0; round < 100 && plugin.evaluate().isEmpty; round++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 20)), - ); - await tester.pump(); + // runAsync 裡讓它跑,直到音源出現。 + Future until(Finder finder) async { + for (var round = 0; round < 100 && finder.evaluate().isEmpty; round++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } } - expect(find.text('FMP Dev'), findsOneWidget); - expect(find.text('dev'), findsOneWidget); - expect(find.text('/data/fmp-dev'), findsOneWidget); - expect(plugin, findsOneWidget); + final source = find.widgetWithText(ChoiceChip, 'FMP Test Plugin'); + await until(source); + expect(source, findsOneWidget); + + // 內附測試插件的搜尋(真的 QuickJS):離線也有結果。 + await tester.enterText(find.byType(TextField), 'tone'); + await tester.testTextInput.receiveAction(TextInputAction.search); + final result = find.text('Test tone 220 Hz (tone)'); + await until(result); + expect(result, findsOneWidget); + expect(find.text('Load more'), findsOneWidget); }); group('appearance', () { @@ -94,11 +103,18 @@ void main() { }); Locale appLocale(WidgetTester tester) => - Localizations.localeOf(tester.element(find.text('FMP Dev'))); + Localizations.localeOf(tester.element(find.byType(AppShell))); + + /// 開到設定頁(外觀設定在那裡)。 + Future openSettings(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.settings_outlined)); + await settle(tester); + } testWidgets('an unset language follows the system', (tester) async { await pumpApp(tester); await settle(tester); + await openSettings(tester); expect(appLocale(tester), const Locale('en')); expect(find.text('Theme'), findsOneWidget); @@ -109,6 +125,7 @@ void main() { ) async { await pumpApp(tester); await settle(tester); + await openSettings(tester); await tester.tap(find.text('繁體中文')); await settle(tester); @@ -123,7 +140,7 @@ void main() { expect(find.text('主題'), findsOneWidget); // Material 的內建字串也是繁中。 expect( - MaterialLocalizations.of(tester.element(find.text('FMP Dev'))) + MaterialLocalizations.of(tester.element(find.byType(AppShell))) .okButtonLabel, '確定', ); @@ -138,7 +155,7 @@ void main() { ); await settle(tester); expect(appLocale(tester), const Locale('en')); - expect(find.text('FMP Dev'), findsOneWidget); + expect(find.text('Theme'), findsOneWidget); }); testWidgets('CJK text takes the glyphs of the UI language', (tester) async { @@ -169,10 +186,11 @@ void main() { }; await pumpApp(tester); await settle(tester); + await openSettings(tester); // 英文介面:漢字用繁中字形(ADR 0024 §決定 2),英文的 Material 字串不變。 expect(appLocale(tester), const Locale('en')); - expect(textLocale(find.text('FMP Dev')), hant); + expect(textLocale(find.text('Theme')), hant); expect(textLocale(find.text('Dark')), hant); for (final MapEntry(key: text, value: locale) in endonyms.entries) { expect(textLocale(text), locale, reason: 'endonyms keep their own'); @@ -180,7 +198,7 @@ void main() { await tester.tap(find.text('简体中文')); await settle(tester); - expect(textLocale(find.text('FMP Dev')), hans); + expect(textLocale(find.text('主题')), hans); expect(textLocale(find.text('深色')), hans); for (final MapEntry(key: text, value: locale) in endonyms.entries) { expect(textLocale(text), locale, reason: 'endonyms keep their own'); @@ -188,15 +206,16 @@ void main() { await tester.tap(find.text('繁體中文')); await settle(tester); - expect(textLocale(find.text('FMP Dev')), hant); + expect(textLocale(find.text('主題')), hant); expect(textLocale(find.text('深色')), hant); }); testWidgets('switching the theme changes the brightness', (tester) async { await pumpApp(tester); await settle(tester); + await openSettings(tester); Brightness brightness() => - Theme.of(tester.element(find.text('FMP Dev'))).brightness; + Theme.of(tester.element(find.byType(AppShell))).brightness; await tester.tap(find.text('Dark')); await settle(tester); @@ -205,7 +224,6 @@ void main() { await tester.tap(find.text('Light')); await settle(tester); expect(brightness(), Brightness.light); - expect(find.text('FMP Dev'), findsOneWidget); }); testWidgets('the theme uses the platform fonts for the UI language', ( @@ -222,8 +240,9 @@ void main() { ); final log = await pumpApp(tester, capabilities: capabilities); await settle(tester); + await openSettings(tester); List? fallback() => - Theme.of(tester.element(find.text('FMP Dev'))) + Theme.of(tester.element(find.byType(AppShell))) .textTheme .bodyMedium ?.fontFamilyFallback; diff --git a/app/test/fixtures/plugins/test_plugin/README.md b/app/test/fixtures/plugins/test_plugin/README.md index 08fd8a6d1..5a66171f6 100644 --- a/app/test/fixtures/plugins/test_plugin/README.md +++ b/app/test/fixtures/plugins/test_plugin/README.md @@ -5,7 +5,8 @@ ADR 0015 §決定 6 的測試插件:合成資料、不連網。執行環境的 `flutter.assets`),prod 不含。 - `test_plugin.js`:安裝檔(標頭 manifest + ES module),能力 `search`、 - `resolveStream`。 + `resolveStream`。搜尋任何關鍵字都回三首(每頁兩首);關鍵字剛好是 `fail` 時以 + `RateLimited` 失敗,實機不連網也能看到錯誤提示。 - `tone.wav`:2 秒 440 Hz 正弦波,16 kHz 單聲道 16-bit PCM,64 044 bytes。 ## `tone.wav` 的來源與授權 diff --git a/app/test/fixtures/plugins/test_plugin/test_plugin.js b/app/test/fixtures/plugins/test_plugin/test_plugin.js index e95a6ea5f..2c2b57be1 100644 --- a/app/test/fixtures/plugins/test_plugin/test_plugin.js +++ b/app/test/fixtures/plugins/test_plugin/test_plugin.js @@ -17,8 +17,15 @@ const TONES = [220, 440, 880]; const PAGE_SIZE = 2; const TONE_ASSET = 'asset:///test/fixtures/plugins/test_plugin/tone.wav'; +// 關鍵字剛好是它時,搜尋以限流失敗:實機不連網也能看到錯誤提示(ADR 0027 +// 「測試插件不足以涵蓋某類 UI 時先補測試插件」)。 +const FAIL_KEYWORD = 'fail'; + // 標題的前綴可以用 storage 改(`titlePrefix`),順便走一次非同步的宿主函式。 export async function search(query) { + if (query.keyword === FAIL_KEYWORD) { + throw { fmpError: 'RateLimited', message: 'forced failure for on-device checks' }; + } const prefix = (await fmp.storage.get('titlePrefix')) ?? 'Test tone'; const start = (query.page - 1) * PAGE_SIZE; const items = TONES.slice(start, start + PAGE_SIZE).map((hz) => ({ diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart index a269a03c3..ef3795fbd 100644 --- a/app/test/flutter_test_config.dart +++ b/app/test/flutter_test_config.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:alchemist/alchemist.dart'; import 'package:flutter_test/flutter_test.dart'; import 'support/quickjs.dart'; @@ -19,7 +20,14 @@ Future testExecutable(FutureOr Function() testMain) async { HttpOverrides.global = NoNetworkHttpOverrides(); // 插件執行環境的測試要真的跑 QuickJS(見 support/quickjs.dart)。 loadQuickJsForTests(); - await testMain(); + // golden(alchemist)只比 CI 版:文字畫成色塊、不畫陰影,Windows 產生的檔 + // 在 CI 的 Linux 上也對得上。平台版(真的字)各平台的字形不同,不提交也不跑。 + await AlchemistConfig.runWithConfig( + config: const AlchemistConfig( + platformGoldensConfig: PlatformGoldensConfig(enabled: false), + ), + run: () async => testMain(), + ); } /// 建立 [HttpClient] 就拋錯的 [HttpOverrides]。 diff --git a/app/test/playback/dev_playback_entry_test.dart b/app/test/playback/dev_playback_entry_test.dart deleted file mode 100644 index 0edb84880..000000000 --- a/app/test/playback/dev_playback_entry_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -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_source_plugin.dart b/app/test/playback/fake_source_plugin.dart index 5c886fd44..bc809c9b9 100644 --- a/app/test/playback/fake_source_plugin.dart +++ b/app/test/playback/fake_source_plugin.dart @@ -4,21 +4,33 @@ import 'package:fmp/plugins/manifest/plugin_manifest.dart'; import 'package:fmp/plugins/source_dto.dart'; import 'package:fmp/plugins/source_plugin.dart'; -/// 只有 `resolveStream` 的插件,回傳由 [respond] 決定;記下每次請求。 +/// 假的插件:`resolveStream` 的回傳由 [respond] 決定;給了 [onSearch] 才有 +/// `search` 能力。記下每次請求。 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'], - ); + FakeSourcePlugin( + this.respond, { + String id = 'fmp-test', + String name = 'Fake', + this.onSearch, + }) : manifest = PluginManifest( + id: id, + name: name, + version: '1.0.0', + author: 'FMP tests', + capabilities: { + PluginCapability.resolveStream, + if (onSearch != null) PluginCapability.search, + }, + allowedHosts: const ['cdn.example'], + ); FutureOr> Function(StreamRequest request) respond; + /// 搜尋的回傳;`null` 表示沒有 `search` 能力。 + final FutureOr Function(SearchQuery query)? onSearch; + final requests = []; + final searches = []; /// 對 [sourceId] 解析了幾次。 int resolvedCount(String sourceId) => @@ -34,8 +46,12 @@ final class FakeSourcePlugin implements SourcePlugin { Future get whenUnresponsive => Completer().future; @override - Future search(SearchQuery query) => - throw UnimplementedError('search'); + Future search(SearchQuery query) async { + final onSearch = this.onSearch; + if (onSearch == null) throw UnimplementedError('search'); + searches.add(query); + return onSearch(query); + } @override Future> resolveStream(StreamRequest request) async { diff --git a/app/test/plugins/test_plugin_bundle_test.dart b/app/test/plugins/test_plugin_bundle_test.dart new file mode 100644 index 000000000..f9825c2dd --- /dev/null +++ b/app/test/plugins/test_plugin_bundle_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:yaml/yaml.dart'; + +import 'plugin_harness.dart'; + +// 內附測試插件(`fmp-test`)是 dev 實機驗證的離線音源:以 `--fmp-dev-plugin` +// 安裝後可以搜尋、播放,不連網(ADR 0027 §決定 1)。它的串流指向 dev flavor +// 打包的音檔,prod 不打包(app/AGENTS.md § 插件)。 + +void main() { + test('every search result plays a bundled asset', () async { + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + final ids = []; + for (var page = 1; ; page++) { + final result = await plugin.search(SearchQuery(keyword: 'x', page: page)); + ids.addAll([for (final item in result.items) item.sourceId]); + if (!result.hasMore) break; + } + expect(ids, hasLength(greaterThanOrEqualTo(2))); + + for (final id in ids) { + final candidates = await plugin.resolveStream( + StreamRequest( + sourceId: id, + formats: [StreamFormat(container: 'wav', codec: 'pcm_s16le')], + ), + ); + expect(candidates.first.url.scheme, 'asset'); + expect( + candidates.first.url.path, + startsWith('/test/fixtures/plugins/test_plugin/'), + ); + } + }); + + test( + 'searching "fail" fails as rate limited, for an offline toast', + () async { + final plugin = await PluginHarness().load( + testPluginFile.readAsStringSync(), + ); + + await expectLater( + plugin.search(SearchQuery(keyword: 'fail')), + throwsA(isA()), + ); + // 只有剛好是它才失敗。 + expect( + (await plugin.search(SearchQuery(keyword: 'failure'))).items, + isNotEmpty, + ); + }, + ); + + 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']), + ), + ), + ); + }); +} diff --git a/app/test/ui/format_and_artwork_test.dart b/app/test/ui/format_and_artwork_test.dart new file mode 100644 index 000000000..111fcb609 --- /dev/null +++ b/app/test/ui/format_and_artwork_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/ui/artwork/artwork_image.dart'; +import 'package:fmp/ui/format/duration_text.dart'; + +void main() { + group('formatDuration', () { + for (final (duration, text) in [ + (Duration.zero, '0:00'), + (const Duration(seconds: 5), '0:05'), + (const Duration(minutes: 3, seconds: 5, milliseconds: 900), '3:05'), + (const Duration(minutes: 59, seconds: 59), '59:59'), + (const Duration(hours: 1, minutes: 2, seconds: 3), '1:02:03'), + (const Duration(seconds: -3), '0:00'), + ]) { + test('$duration is $text', () => expect(formatDuration(duration), text)); + } + }); + + // ADR 0016 §決定 4:最接近且不小於顯示尺寸的一張。 + group('pickArtwork', () { + Artwork art(String name, [int? width]) => + Artwork(url: Uri.parse('https://img.example/$name'), width: width); + String? pick(List artwork, double pixels) => + pickArtwork(artwork, pixels)?.url.pathSegments.last; + + test('the smallest one that is big enough', () { + expect(pick([art('s', 64), art('l', 480), art('m', 160)], 144), 'm'); + expect(pick([art('s', 64), art('m', 144)], 144), 'm'); + }); + + test('all too small: one without a width, then the largest', () { + expect(pick([art('s', 64), art('o'), art('m', 100)], 144), 'o'); + expect(pick([art('s', 64), art('m', 100)], 144), 'm'); + }); + + test('a known big-enough one beats one without a width', () { + expect(pick([art('o'), art('l', 480)], 144), 'l'); + }); + + test('nothing to pick', () => expect(pick([], 144), isNull)); + }); +} diff --git a/app/test/ui/guidelines_test.dart b/app/test/ui/guidelines_test.dart index 05b52763b..0c9d1e59b 100644 --- a/app/test/ui/guidelines_test.dart +++ b/app/test/ui/guidelines_test.dart @@ -15,10 +15,11 @@ import 'package:fmp/ui/toast/toaster.dart'; import 'package:material_ui/material_ui.dart'; import '../support/memory_database.dart'; +import 'support/shell_harness.dart'; -// ADR 0024 §如何確認:淺色與深色主題下通過點擊區與對比度 guideline。M1 還沒有 -// 正式頁面(12b),這裡以示範畫面驗證主題本身:M3 元件、token 的語意色、四種 -// 提示,加上身分頁用的外觀設定控制項。 +// ADR 0024 §如何確認:淺色與深色主題下通過點擊區與對比度 guideline。示範畫面 +// 驗證主題本身(M3 元件、token 的語意色、四種提示);外殼裡的搜尋頁、設定頁與 +// 播放列在窄與寬兩種視窗各驗一次。 /// 示範畫面:文字角色、常見按鈕、外觀設定控制項。 class _Demo extends StatelessWidget { @@ -149,6 +150,53 @@ void main() { handle.dispose(); }); } + + for (final size in const [Size(400, 800), Size(1000, 700)]) { + final width = size.width; + + testWidgets('search, before searching, at $width', (tester) async { + final handle = tester.ensureSemantics(); + await ShellHarness().pumpShell( + tester, + size: size, + brightness: brightness, + ); + + await expectGuidelines(tester); + handle.dispose(); + }); + + testWidgets('search results and the player bar at $width', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final h = ShellHarness(); + await h.pumpShell(tester, size: size, brightness: brightness); + await tester.enterText(find.byType(TextField), 'song'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + await tester.tap(find.text('Song a')); + await tester.pump(const Duration(milliseconds: 200)); + // 輸入框沒有焦點:游標閃爍會讓對比度的取樣時好時壞。 + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + + await expectGuidelines(tester); + handle.dispose(); + }); + + testWidgets('settings at $width', (tester) async { + final handle = tester.ensureSemantics(); + final h = ShellHarness(); + await h.pumpShell(tester, size: size, brightness: brightness); + await tester.tap(find.text('Settings').first); + await h.loadSettings(tester); + await tester.pumpAndSettle(); + + await expectGuidelines(tester); + handle.dispose(); + }); + } }); } } diff --git a/app/test/ui/player/goldens/ci/player_bar_compact.png b/app/test/ui/player/goldens/ci/player_bar_compact.png new file mode 100644 index 000000000..b7fe7d3ed Binary files /dev/null and b/app/test/ui/player/goldens/ci/player_bar_compact.png differ diff --git a/app/test/ui/player/goldens/ci/player_bar_expanded.png b/app/test/ui/player/goldens/ci/player_bar_expanded.png new file mode 100644 index 000000000..207b3e94e Binary files /dev/null and b/app/test/ui/player/goldens/ci/player_bar_expanded.png differ diff --git a/app/test/ui/player/goldens/ci/player_bar_medium.png b/app/test/ui/player/goldens/ci/player_bar_medium.png new file mode 100644 index 000000000..3ffd4dc6c Binary files /dev/null and b/app/test/ui/player/goldens/ci/player_bar_medium.png differ diff --git a/app/test/ui/player/player_bar_golden_test.dart b/app/test/ui/player/player_bar_golden_test.dart new file mode 100644 index 000000000..6fb88a90c --- /dev/null +++ b/app/test/ui/player/player_bar_golden_test.dart @@ -0,0 +1,89 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/playback/queue_model.dart'; +import 'package:fmp/ui/i18n/ui_locale.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/player/player_bar.dart'; +import 'package:fmp/ui/player/queue_tracks.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../support/shell_harness.dart'; + +// ADR 0024 §如何確認:播放列三段寬度的 golden,只守版面結構(控制項的位置、 +// 曲名與進度條的排法),文字畫成色塊(test/flutter_test_config.dart)。更新: +// `flutter test --update-goldens test/ui/player/player_bar_golden_test.dart`。 + +/// 播放中、在 1:05 的一首,只有寬度不同。 +Widget _bar(double width) { + final track = summary('a'); + return SizedBox( + width: width, + height: 120, + child: ProviderScope( + overrides: [ + translationsProvider.overrideWithValue(AppLocale.zhTw.buildSync()), + playbackQueueProvider.overrideWithValue( + AsyncData( + QueueState( + tracks: const [ + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'a'), + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'b'), + ], + currentIndex: 0, + ), + ), + ), + playbackStateProvider.overrideWithValue(const AsyncData(Playing())), + playbackProgressProvider.overrideWithValue( + const AsyncData( + PlaybackProgress( + position: Duration(minutes: 1, seconds: 5), + duration: Duration(minutes: 3, seconds: 5), + ), + ), + ), + queueTracksProvider.overrideWithBuild( + (ref, notifier) => {trackKeyOf(track): track}, + ), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildAppTheme( + Brightness.light, + fontFamilyFallback: const [], + textLocale: textLocaleOf(LocaleSetting.zhTw), + ), + locale: flutterLocaleOf(LocaleSetting.zhTw), + supportedLocales: supportedFlutterLocales, + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: const Align( + alignment: Alignment.bottomCenter, + child: WindowClassScope(child: PlayerBar()), + ), + ), + ), + ); +} + +void main() { + // 一個寬度一個檔、不用 GoldenTestScenario:它的名稱標籤由 alchemist 自己 + // 排版,寬度在 Windows 與 Linux 差一個像素(色塊的邊),我們的元件則逐像素 + // 相同(2026-09-30 以 Flutter 3.47.5 在兩個平台比對)。 + for (final (name, width) in [ + ('compact', 360.0), + ('medium', 720.0), + ('expanded', 1000.0), + ]) { + goldenTest( + 'the player bar, $name ($width wide)', + fileName: 'player_bar_$name', + builder: () => _bar(width), + ); + } +} diff --git a/app/test/ui/player/player_bar_test.dart b/app/test/ui/player/player_bar_test.dart new file mode 100644 index 000000000..75ce9417a --- /dev/null +++ b/app/test/ui/player/player_bar_test.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/player/player_bar.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../playback/fake_source_plugin.dart'; +import '../support/shell_harness.dart'; + +/// 播放列單獨放在視窗底部,寬度就是視窗寬度。 +Future pumpBar( + WidgetTester tester, { + double width = 1000, + ShellHarness? harness, +}) async { + final h = harness ?? ShellHarness(); + await h.pumpApp( + tester, + const Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: WindowClassScope(child: PlayerBar()), + ), + ), + size: Size(width, 600), + ); + return h; +} + +void main() { + const play = 'Play (Space)'; + const pause = 'Pause (Space)'; + const previous = 'Previous (Ctrl+←)'; + const next = 'Next (Ctrl+→)'; + + testWidgets('takes no space while nothing is queued', (tester) async { + await pumpBar(tester); + + expect(tester.getSize(find.byType(PlayerBar)).height, 0); + }); + + // ADR 0024 §決定 5,只放 M1 有的功能(音量、隨機、循環、輸出裝置在 M2)。 + group('controls per width', () { + for (final (width, controls) in [ + (360.0, {pause, next}), + (599.0, {pause, next}), + (600.0, {previous, pause, next}), + (839.0, {previous, pause, next}), + (840.0, {previous, pause, next}), + (1600.0, {previous, pause, next}), + ]) { + testWidgets('$width wide: $controls; the title keeps 160dp', ( + tester, + ) async { + final h = await pumpBar(tester, width: width); + await h.play(tester, [summary('a'), summary('b')]); + await tester.pump(const Duration(milliseconds: 100)); + + final tooltips = { + for (final button in tester.widgetList( + find.descendant( + of: find.byType(PlayerBar), + matching: find.byType(IconButton), + ), + )) + button.tooltip, + }; + expect(tooltips, controls); + expect(find.byType(Slider), findsOneWidget); + expect( + tester.getSize(find.byKey(PlayerBar.titleKey)).width, + greaterThanOrEqualTo(160), + ); + expect(find.text('Song a'), findsOneWidget); + expect(find.text('Uploader a'), findsOneWidget); + }); + } + }); + + group('state from the controller', () { + testWidgets('loading shows a labelled spinner; pressing it pauses', ( + tester, + ) async { + final pending = Completer>(); + final h = ShellHarness(); + h.plugin.respond = (_) => pending.future; + await pumpBar(tester, harness: h); + await h.play(tester, [summary('a')]); + + expect(h.controller.state, isA()); + expect(find.bySemanticsLabel('Loading'), findsOneWidget); + expect(find.byTooltip(pause), findsOneWidget); + + await tester.tap(find.byTooltip(pause)); + await tester.pump(); + pending.complete([candidate('a.m4a')]); + await tester.pump(); + await tester.pump(); + expect(h.controller.state, isA()); + expect(find.byTooltip(play), findsOneWidget); + }); + + testWidgets('play, pause, previous and next go to the controller', ( + tester, + ) async { + final h = await pumpBar(tester); + await h.play(tester, [summary('a'), summary('b')]); + await tester.pump(const Duration(milliseconds: 100)); + expect(h.controller.state, isA()); + + await tester.tap(find.byTooltip(pause)); + await tester.pump(); + expect(h.controller.state, isA()); + await tester.tap(find.byTooltip(play)); + await tester.pump(); + expect(h.controller.state, isA()); + + await tester.tap(find.byTooltip(next)); + await tester.pump(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 1); + expect(find.text('Song b'), findsOneWidget); + // 最後一首:下一首停用。 + expect( + tester + .widget( + find.widgetWithIcon(IconButton, Icons.skip_next), + ) + .onPressed, + isNull, + ); + + await tester.tap(find.byTooltip(previous)); + await tester.pump(const Duration(milliseconds: 100)); + expect(h.controller.queue.currentIndex, 0); + }); + + testWidgets('dragging the progress bar seeks on release', (tester) async { + final h = await pumpBar(tester); + await h.play(tester, [summary('a')]); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('3:00'), findsOneWidget, reason: 'the duration'); + + final slider = find.byType(Slider); + final gesture = await tester.startGesture(tester.getCenter(slider)); + await tester.pump(); + await gesture.moveBy(const Offset(40, 0)); + await tester.pump(); + // 拖動中只改畫面,還沒 seek。 + expect( + h.container(tester).read(playbackProgressProvider).value!.position, + lessThan(const Duration(seconds: 5)), + ); + await gesture.up(); + await tester.pump(); + + // 從中間附近開始播:位置跳到約 1:30。 + await tester.pump(const Duration(milliseconds: 60)); + final position = h + .container(tester) + .read(playbackProgressProvider) + .value! + .position + .inSeconds; + expect(position, greaterThan(60)); + }); + }); +} diff --git a/app/test/ui/search/search_page_test.dart b/app/test/ui/search/search_page_test.dart new file mode 100644 index 000000000..8fefadf1d --- /dev/null +++ b/app/test/ui/search/search_page_test.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +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/plugins/source_dto.dart'; +import 'package:fmp/ui/player/player_bar.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../playback/fake_source_plugin.dart'; +import '../support/shell_harness.dart'; + +void main() { + Future search(WidgetTester tester, String keyword) async { + await tester.enterText(find.byType(TextField), keyword); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + await tester.pump(); + } + + group('states', () { + testWidgets('no source can search', (tester) async { + final h = ShellHarness(sources: []); + await h.pumpShell(tester); + + expect(find.text('No sources to search'), findsOneWidget); + expect(find.byType(ChoiceChip), findsNothing); + }); + + testWidgets('before searching, while loading, with no results', ( + tester, + ) async { + final pending = Completer(); + final h = ShellHarness(onSearch: (_) => pending.future); + await h.pumpShell(tester); + expect(find.text('Type a keyword to search'), findsOneWidget); + + await search(tester, ' nothing '); + expect( + find.bySemanticsLabel('Searching'), + findsOneWidget, + reason: 'the spinner has a label', + ); + expect(h.plugin.searches.single.keyword, 'nothing'); + + pending.complete(const SearchPage(items: [], hasMore: false)); + await tester.pump(); + expect(find.text('No results for “nothing”'), findsOneWidget); + }); + + testWidgets('only spaces does not search', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + + await search(tester, ' '); + expect(h.plugin.searches, isEmpty); + expect(find.text('Type a keyword to search'), findsOneWidget); + }); + }); + + testWidgets('results show artwork, title, uploader and duration', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester); + + await search(tester, 'song'); + + expect(find.text('Song a'), findsOneWidget); + expect(find.text('Uploader a'), findsOneWidget); + expect(find.text('3:05'), findsNWidgets(3)); + // 沒有封面時是佔位圖,不發請求。 + expect(find.byIcon(Icons.music_note), findsNWidgets(3)); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('an error goes to a toast, and retry searches again', ( + tester, + ) async { + var fail = true; + final h = ShellHarness( + onSearch: (query) => fail + ? throw RateLimited(pluginId: 'fmp-test') + : SearchPage(items: [summary('a')], hasMore: false), + ); + await h.pumpShell(tester); + + await search(tester, 'song'); + await tester.pumpAndSettle(); + expect( + find.text('Too many requests to fmp-test. Try again later.'), + findsOneWidget, + ); + expect(find.text('Search failed'), findsOneWidget); + expect( + h.log.history.where((record) => record.message == 'Search failed'), + hasLength(1), + ); + + fail = false; + await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.pump(); + expect(find.text('Song a'), findsOneWidget); + expect(h.plugin.searches, hasLength(2)); + }); + + testWidgets('load more appends the next page', (tester) async { + final h = ShellHarness( + onSearch: (query) => SearchPage( + items: [summary('p${query.page}')], + hasMore: query.page < 2, + ), + ); + await h.pumpShell(tester); + await search(tester, 'song'); + expect(find.text('Song p1'), findsOneWidget); + + await tester.tap(find.text('Load more')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Song p1'), findsOneWidget); + expect(find.text('Song p2'), findsOneWidget); + expect(find.text('Load more'), findsNothing); + expect([for (final query in h.plugin.searches) query.page], [1, 2]); + }); + + // 排序在兩次請求之間變了時,下一頁會重複上一頁已有的曲目(B 站搜尋會)。 + testWidgets('load more skips tracks already in the list', (tester) async { + final h = ShellHarness( + onSearch: (query) => SearchPage( + items: [ + if (query.page == 1) ...[summary('a'), summary('b')], + if (query.page == 2) ...[summary('b'), summary('c')], + ], + hasMore: query.page < 2, + ), + ); + await h.pumpShell(tester); + await search(tester, 'song'); + + await tester.tap(find.text('Load more')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Song b'), findsOneWidget); + await tester.tap(find.text('Song c')); + await tester.pump(); + expect(h.controller.queue.tracks.map((track) => track.sourceId), [ + 'a', + 'b', + 'c', + ]); + expect(h.controller.queue.currentIndex, 2); + }); + + testWidgets('a newer search wins over a slower older one', (tester) async { + final slow = Completer(); + final h = ShellHarness( + onSearch: (query) => query.keyword == 'old' + ? slow.future + : SearchPage(items: [summary('new')], hasMore: false), + ); + await h.pumpShell(tester); + + await search(tester, 'old'); + await search(tester, 'new'); + slow.complete(SearchPage(items: [summary('old')], hasMore: false)); + await tester.pump(); + + expect(find.text('Song new'), findsOneWidget); + expect(find.text('Song old'), findsNothing); + }); + + testWidgets('tapping a result plays the whole list from it', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await search(tester, 'song'); + + await tester.tap(find.text('Song b')); + await tester.pump(); + await tester.pump(); + + final queue = h.controller.queue; + expect(queue.tracks, const [ + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'a'), + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'b'), + TrackKeyParts(sourceTypeId: 'fmp-test', sourceId: 'c'), + ]); + expect(queue.currentIndex, 1); + // 播放列以曲目鍵找到顯示資料。 + expect( + find.descendant( + of: find.byType(PlayerBar), + matching: find.text('Song b'), + ), + findsOneWidget, + ); + }); + + group('source chips', () { + testWidgets('switching the source searches it again', (tester) async { + final other = FakeSourcePlugin( + (_) => const [], + id: 'other', + name: 'Other Source', + onSearch: (_) => const SearchPage(items: [], hasMore: false), + ); + final h = ShellHarness(); + h.sources.add(other); + await h.pumpShell(tester); + final chips = tester + .widgetList(find.byType(ChoiceChip)) + .toList(); + expect(chips.map((chip) => chip.selected), [true, false]); + + await search(tester, 'song'); + await tester.tap(find.text('Other Source')); + await tester.pump(); + await tester.pump(); + + expect(other.searches.single.keyword, 'song'); + expect(find.text('No results for “song”'), findsOneWidget); + }); + + testWidgets('tapping the selected chip does not search again', ( + tester, + ) async { + final h = ShellHarness(); + h.sources.add( + FakeSourcePlugin( + (_) => const [], + id: 'other', + name: 'Other Source', + onSearch: (_) => const SearchPage(items: [], hasMore: false), + ), + ); + await h.pumpShell(tester); + await search(tester, 'song'); + + // 還沒點過任何 chip:選的是清單的第一個。 + await tester.tap(find.text('Test Source')); + await tester.pump(); + await tester.pump(); + + expect(h.plugin.searches, hasLength(1)); + expect(find.text('Song a'), findsOneWidget); + }); + + testWidgets('the row scrolls sideways on a narrow window', (tester) async { + final h = ShellHarness(sources: []); + for (var i = 0; i < 8; i++) { + h.sources.add( + FakeSourcePlugin( + (_) => const [], + id: 'source-$i', + name: 'Source number $i', + onSearch: (_) => const SearchPage(items: [], hasMore: false), + ), + ); + } + await h.pumpShell(tester, size: const Size(400, 800)); + final last = find.text('Source number 7'); + expect(last.hitTestable(), findsNothing); + + await tester.scrollUntilVisible( + last, + 200, + scrollable: find + .ancestor(of: last, matching: find.byType(Scrollable)) + .first, + ); + expect(last.hitTestable(), findsOneWidget); + }); + }); + + // ToastHost 在 Navigator 外面多包了一個 Overlay,它成了 root overlay:文字 + // 選取工具列與放大鏡插在那裡(12a 審查留下的)。 + group('text selection over the toast host', () { + testWidgets('long press shows the toolbar and the magnifier', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await tester.enterText(find.byType(TextField), 'hello world'); + await tester.pump(); + + final field = find.byType(EditableText); + final gesture = await tester.startGesture( + tester.getTopLeft(field) + const Offset(10, 10), + ); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(find.byType(TextMagnifier), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(TextMagnifier), findsNothing); + final selectAll = find.text('Select all'); + expect(selectAll.hitTestable(), findsOneWidget); + + await tester.tap(selectAll); + await tester.pumpAndSettle(); + final selection = tester.widget(field).controller.selection; + expect((selection.start, selection.end), (0, 'hello world'.length)); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('right click shows the context menu', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await tester.enterText(find.byType(TextField), 'hello world'); + await tester.pump(); + + final field = find.byType(EditableText); + await tester.tapAt( + tester.getTopLeft(field) + const Offset(10, 10), + buttons: kSecondaryMouseButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + final selectAll = find.text('Select all'); + expect(selectAll.hitTestable(), findsOneWidget); + + await tester.tap(selectAll); + await tester.pumpAndSettle(); + final selection = tester.widget(field).controller.selection; + expect((selection.start, selection.end), (0, 'hello world'.length)); + }, variant: TargetPlatformVariant.only(TargetPlatform.windows)); + }); +} diff --git a/app/test/ui/settings/settings_page_test.dart b/app/test/ui/settings/settings_page_test.dart new file mode 100644 index 000000000..684c881e8 --- /dev/null +++ b/app/test/ui/settings/settings_page_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/ui/settings/appearance_controls.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../support/shell_harness.dart'; + +void main() { + Future openSettings(WidgetTester tester, double width) async { + final h = ShellHarness(); + await h.pumpShell(tester, size: Size(width, 800)); + await tester.tap(find.text('Settings').first); + await h.loadSettings(tester); + expect(find.byType(AppearanceControls), findsOneWidget); + expect(find.byType(RadioListTile), findsNWidgets(4)); + } + + // 內容區的寬度:視窗減掉導覽(rail 80、抽屜 360)。 + testWidgets( + 'expanded and wider: groups on the left, the group on the right', + (tester) async { + await openSettings(tester, 1000); + + final group = tester.widget( + find.widgetWithText(ListTile, 'Appearance'), + ); + expect(group.selected, isTrue); + expect(find.byType(VerticalDivider), findsOneWidget); + expect( + tester.getCenter(find.widgetWithText(ListTile, 'Appearance')).dx, + lessThan(tester.getTopLeft(find.byType(AppearanceControls)).dx), + ); + }, + ); + + for (final width in [400.0, 700.0]) { + testWidgets('$width wide: one column', (tester) async { + await openSettings(tester, width); + + expect(find.byType(VerticalDivider), findsNothing); + expect(find.widgetWithText(ListTile, 'Appearance'), findsNothing); + expect(find.text('Appearance'), findsOneWidget, reason: 'the heading'); + }); + } + + testWidgets('both settings can go back to following the system', ( + tester, + ) async { + await openSettings(tester, 1000); + + expect(find.text('System'), findsOneWidget, reason: 'theme'); + expect(find.text('System default'), findsOneWidget, reason: 'language'); + }); +} diff --git a/app/test/ui/shell/app_shell_test.dart b/app/test/ui/shell/app_shell_test.dart new file mode 100644 index 000000000..32564dd62 --- /dev/null +++ b/app/test/ui/shell/app_shell_test.dart @@ -0,0 +1,365 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/core/errors/app_error.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/playback_state.dart'; +import 'package:fmp/ui/player/player_bar.dart'; +import 'package:fmp/ui/search/search_page.dart'; +import 'package:fmp/ui/settings/settings_page.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../support/shell_harness.dart'; + +void main() { + group('navigation per window class (ADR 0024 §決定 3)', () { + for (final (width, expected) in [ + (400.0, NavigationBar), + (700.0, NavigationRail), + (1000.0, NavigationRail), + (1400.0, NavigationDrawer), + (1700.0, NavigationDrawer), + ]) { + testWidgets('$width wide uses $expected', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester, size: Size(width, 800)); + + for (final type in [NavigationBar, NavigationRail, NavigationDrawer]) { + expect( + find.byType(type), + type == expected ? findsOneWidget : findsNothing, + reason: '$type at $width', + ); + } + }); + } + + testWidgets('selecting a destination switches the page', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + expect(find.byType(SearchPage).hitTestable(), findsOneWidget); + + await tester.tap(find.text('Settings')); + await tester.pump(); + expect(find.byType(SettingsPage).hitTestable(), findsOneWidget); + expect(find.byType(SearchPage).hitTestable(), findsNothing); + }); + + testWidgets('pages read the width of the content area', (tester) async { + final h = ShellHarness(); + // 視窗 900 是 expanded,扣掉導覽列之後的內容區是 medium。 + await h.pumpShell(tester, size: const Size(900, 700)); + await tester.tap(find.text('Settings')); + await tester.pump(); + + expect(find.byType(VerticalDivider), findsNothing); + }); + }); + + group('the bottom inset for toasts (ADR 0023 §決定 2)', () { + double inset(WidgetTester tester, ShellHarness h) => + h.container(tester).read(toastBottomInsetProvider); + + testWidgets('compact: the player bar and the navigation bar', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester, size: const Size(400, 800)); + await tester.pump(); + final navigationBar = tester.getSize(find.byType(NavigationBar)).height; + expect(inset(tester, h), navigationBar); + + await h.play(tester, [summary('a')]); + await tester.pump(); + final playerBar = tester.getSize(find.byType(PlayerBar)).height; + expect(playerBar, greaterThan(0)); + expect(inset(tester, h), navigationBar + playerBar); + }); + + testWidgets('wider: only the player bar, 0 without one', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await tester.pump(); + expect(inset(tester, h), 0); + + await h.play(tester, [summary('a')]); + await tester.pump(); + expect(inset(tester, h), tester.getSize(find.byType(PlayerBar)).height); + }); + + testWidgets('a toast sits above the player bar', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await h.play(tester, [summary('a')]); + await tester.pump(); + + h.toaster.info('Above the bar'); + await tester.pumpAndSettle(); + final toast = tester.getRect( + find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ), + ); + expect( + toast.bottom, + lessThanOrEqualTo(tester.getRect(find.byType(PlayerBar)).top), + ); + }); + }); + + group('shortcuts (ADR 0024 §決定 8)', () { + Duration position(ShellHarness h, WidgetTester tester) => + h.container(tester).read(playbackProgressProvider).value!.position; + + Future playing(WidgetTester tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await h.play(tester, [summary('a'), summary('b'), summary('c')]); + await tester.pump(const Duration(milliseconds: 200)); + expect(h.controller.state, isA()); + return h; + } + + testWidgets('space toggles playback outside an input', (tester) async { + final h = await playing(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(h.controller.state, isA()); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(h.controller.state, isA()); + }); + + testWidgets('text-editing keys in the search field stay in the field', ( + tester, + ) async { + final h = await playing(tester); + await tester.tap(find.byType(TextField)); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'lofi'); + + // 外殼不處理,交給 App 根的文字編輯快捷鍵(停止傳遞、不算處理), + // 平台再把字元送進輸入框。 + final space = await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(space, isFalse, reason: 'left to the text input'); + tester.testTextInput.enterText('lofi beats'); + await tester.pump(); + expect(h.controller.state, isA()); + expect(find.text('lofi beats'), findsOneWidget); + + // Ctrl/Shift 加方向鍵在輸入框裡是移動游標與選取。 + final before = position(h, tester); + for (final modifier in [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + ]) { + await tester.sendKeyDownEvent(modifier); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(modifier); + await tester.pump(const Duration(milliseconds: 200)); + } + expect(h.controller.queue.currentIndex, 0); + expect(h.backend.openedAt, hasLength(1)); + // 只有播放本身前進的 0.4 秒,沒有快轉 5 秒。 + expect( + position(h, tester) - before, + lessThan(const Duration(seconds: 1)), + ); + }, variant: TargetPlatformVariant.only(TargetPlatform.windows)); + + testWidgets('Ctrl+arrows change the track, Shift+arrows seek', ( + tester, + ) async { + final h = await playing(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 200)); + expect(h.controller.queue.currentIndex, 1); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 200)); + expect(h.controller.queue.currentIndex, 0); + + await tester.pump(const Duration(seconds: 10)); + final before = h.backend.openedAt.length; + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(const Duration(milliseconds: 60)); + expect(h.backend.openedAt, hasLength(before), reason: 'no reload'); + final forward = position(h, tester); + expect(forward, greaterThanOrEqualTo(const Duration(seconds: 15))); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(const Duration(milliseconds: 60)); + expect(position(h, tester), lessThan(forward)); + }); + + testWidgets('Ctrl+F goes to search and focuses the field', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await tester.tap(find.text('Settings')); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SearchPage).hitTestable(), findsOneWidget); + final field = tester.widget(find.byType(EditableText)); + expect(field.focusNode.hasPrimaryFocus, isTrue); + }); + + // 已經在那一頁時不換頁、不重建:焦點不能等下一幀(閒著時不會有下一幀)。 + testWidgets('Ctrl+F and Ctrl+, on their own page move focus at once', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester); + final field = tester + .widget(find.byType(EditableText)) + .focusNode; + + Future ctrl(LogicalKeyboardKey key) async { + await tester.sendKeyEvent(LogicalKeyboardKey.f6); + await tester.pumpAndSettle(); + expect(field.hasFocus, isFalse, reason: 'F6 went to the navigation'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.idle(); + } + + await ctrl(LogicalKeyboardKey.keyF); + expect(field.hasPrimaryFocus, isTrue); + + await tester.tap(find.text('Settings')); + await tester.pump(); + await ctrl(LogicalKeyboardKey.comma); + final focus = FocusManager.instance.primaryFocus?.context; + expect(focus?.findAncestorWidgetOfExactType(), isNotNull); + }); + + testWidgets('Ctrl+, goes to settings, also from the search field', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.comma); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SettingsPage).hitTestable(), findsOneWidget); + expect( + tester + .widget( + find.byType(EditableText, skipOffstage: false), + ) + .focusNode + .hasFocus, + isFalse, + ); + }); + }); + + group('focus regions (ADR 0024 §決定 8)', () { + /// 焦點在哪一區:導覽、內容、播放列。 + String? region(WidgetTester tester) { + final context = FocusManager.instance.primaryFocus?.context; + if (context == null) return null; + if (context.findAncestorWidgetOfExactType() != null) { + return 'navigation'; + } + if (context.findAncestorWidgetOfExactType() != null) { + return 'player bar'; + } + if (context.findAncestorWidgetOfExactType() != null || + context.findAncestorWidgetOfExactType() != null) { + return 'content'; + } + return null; + } + + Future f6(WidgetTester tester) async { + await tester.sendKeyEvent(LogicalKeyboardKey.f6); + await tester.pump(); + } + + testWidgets('F6 cycles navigation, content and player bar', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await h.play(tester, [summary('a'), summary('b')]); + await tester.pump(const Duration(milliseconds: 200)); + + await f6(tester); + expect(region(tester), 'navigation'); + await f6(tester); + expect(region(tester), 'content'); + await f6(tester); + expect(region(tester), 'player bar'); + await f6(tester); + expect(region(tester), 'navigation'); + }); + + testWidgets('F6 skips the player bar while nothing is queued', ( + tester, + ) async { + final h = ShellHarness(); + await h.pumpShell(tester); + + await f6(tester); + expect(region(tester), 'navigation'); + await f6(tester); + expect(region(tester), 'content'); + await f6(tester); + expect(region(tester), 'navigation'); + }); + + testWidgets('Tab stays inside a region', (tester) async { + final h = ShellHarness(); + await h.pumpShell(tester); + await h.play(tester, [summary('a'), summary('b')]); + await tester.pump(const Duration(milliseconds: 200)); + await f6(tester); + await f6(tester); + await f6(tester); + expect(region(tester), 'player bar'); + + final seen = {}; + for (var i = 0; i < 8; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + seen.add(region(tester)); + } + expect(seen, {'player bar'}); + }); + }); + + testWidgets('playback that stops failed shows a toast', (tester) async { + final h = ShellHarness(); + h.plugin.respond = (_) => throw NotFound(pluginId: 'fmp-test'); + await h.pumpShell(tester); + + await h.play(tester, [summary('a')]); + await tester.pumpAndSettle(); + + expect(h.controller.state, isA()); + expect(find.text('Not found. It may have been removed.'), findsOneWidget); + }); +} diff --git a/app/test/ui/support/shell_harness.dart b/app/test/ui/support/shell_harness.dart new file mode 100644 index 000000000..40341a8cf --- /dev/null +++ b/app/test/ui/support/shell_harness.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/app_material.dart'; +import 'package:fmp/core/core_providers.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/data/providers.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:fmp/domain/track_key.dart'; +import 'package:fmp/i18n/strings.g.dart'; +import 'package:fmp/platform/audio/audio.dart'; +import 'package:fmp/playback/playback_controller.dart'; +import 'package:fmp/playback/playback_providers.dart'; +import 'package:fmp/playback/stream_resolver.dart'; +import 'package:fmp/plugins/source_dto.dart'; +import 'package:fmp/plugins/source_plugin.dart'; +import 'package:fmp/ui/layout/window_class.dart'; +import 'package:fmp/ui/player/queue_tracks.dart'; +import 'package:fmp/ui/search/search_state.dart'; +import 'package:fmp/ui/shell/app_shell.dart'; +import 'package:fmp/ui/theme/app_theme.dart'; +import 'package:fmp/ui/toast/toast_host.dart'; +import 'package:fmp/ui/toast/toaster.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../playback/fake_audio_backend.dart'; +import '../../playback/fake_source_plugin.dart'; +import '../../support/memory_database.dart'; + +/// 一首搜尋結果(插件 `fmp-test`)。 +TrackSummary summary(String id, {Duration? duration}) => TrackSummary( + sourceTypeId: 'fmp-test', + sourceId: id, + title: 'Song $id', + uploader: 'Uploader $id', + duration: duration ?? const Duration(minutes: 3, seconds: 5), +); + +/// 外殼與頁面的測試環境:可以搜尋、可以解析的假插件,假後端上的真 +/// `PlaybackController`,記憶體資料庫與 [Toaster]。介面語言是英文(測試的 +/// 系統語言 `en_US`)。 +final class ShellHarness { + ShellHarness({ + FutureOr Function(SearchQuery query)? onSearch, + List? sources, + }) : plugin = FakeSourcePlugin( + (request) => [candidate('${request.sourceId}.m4a')], + name: 'Test Source', + onSearch: + onSearch ?? + (query) => SearchPage( + items: [ + for (final id in ['a', 'b', 'c']) summary(id), + ], + hasMore: false, + ), + ) { + this.sources = sources ?? [plugin]; + controller = PlaybackController( + backend: backend, + resolver: StreamResolver( + plugin: (id) => id == plugin.manifest.id ? plugin : null, + formats: const [PlayableFormat('mp4', 'aac')], + ), + log: log, + ); + toaster = Toaster( + log: log, + translations: AppLocale.en.buildSync, + sourceName: (_) => null, + ); + addTearDown(toaster.dispose); + } + + final FakeSourcePlugin plugin; + late final List sources; + + /// 曲目長 3 分鐘:測試期間不會自己播完。 + final backend = FakeAudioBackend( + durationOf: (_) => const Duration(minutes: 3), + ); + final log = Log(redactor: Redactor(), minimumLevel: LogLevel.warning); + late final PlaybackController controller; + late final Toaster toaster; + + List get overrides => [ + appDatabaseProvider.overrideWithValue(memoryDatabase()), + logProvider.overrideWithValue(log), + toasterProvider.overrideWithValue(toaster), + searchSourcesProvider.overrideWithValue(AsyncData(sources)), + // 樹拆掉時停掉後端的計時器(測試結束時檢查沒有留下的計時器)。 + playbackControllerProvider.overrideWith((ref) { + ref.onDispose(() { + unawaited(controller.dispose()); + unawaited(backend.dispose()); + }); + return controller; + }), + ]; + + /// 以 [size] 的視窗開 App 的外殼(和 `FmpApp` 同一份 `MaterialApp` 設定)。 + Future pumpShell( + WidgetTester tester, { + Size size = const Size(1000, 700), + Brightness brightness = Brightness.light, + }) => pumpApp(tester, const AppShell(), size: size, brightness: brightness); + + /// 以 [size] 的視窗、App 的 `MaterialApp` 設定與 `ToastHost` 開 [home]。 + Future pumpApp( + WidgetTester tester, + Widget home, { + Size size = const Size(1000, 700), + Brightness brightness = Brightness.light, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: fmpMaterialApp( + title: 'FMP Dev', + locale: LocaleSetting.en, + themeMode: themeModeOf(switch (brightness) { + Brightness.light => ThemeModeSetting.light, + Brightness.dark => ThemeModeSetting.dark, + }), + fontFamilyFallback: const [], + builder: (context, navigator) => + WindowClassScope(child: ToastHost(child: navigator!)), + home: home, + ), + ), + ); + await tester.pump(); + } + + /// 目前的 [ProviderContainer](`pumpApp` 之後)。 + ProviderContainer container(WidgetTester tester) => + ProviderScope.containerOf(tester.element(find.byType(ToastHost))); + + /// 從 [tracks] 的第 [index] 首開始播,像搜尋頁點了一首。 + Future play( + WidgetTester tester, + List tracks, { + int index = 0, + }) async { + container(tester).read(queueTracksProvider.notifier).replace(tracks); + unawaited( + controller.playQueue([ + for (final track in tracks) + TrackKeyParts( + sourceTypeId: track.sourceTypeId, + sourceId: track.sourceId, + ), + ], startIndex: index), + ); + await tester.pump(); + await tester.pump(); + } + + /// 外觀設定從記憶體資料庫讀出來(drift 的串流要真的事件迴圈)。 + Future loadSettings(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } +} diff --git a/app/test/ui/toast/toast_host_test.dart b/app/test/ui/toast/toast_host_test.dart index ad0a07584..7e75be08f 100644 --- a/app/test/ui/toast/toast_host_test.dart +++ b/app/test/ui/toast/toast_host_test.dart @@ -211,6 +211,32 @@ void main() { ); }); + // 鍵盤比外殼高時,提示在鍵盤上面;鍵盤蓋住了安全區,所以位移是鍵盤高度 + // 減掉 viewPadding(Scaffold 已經墊過的安全區),不是再加上去。 + for (final (name, shell) in [ + ('no shell', 0.0), + ('a shell under it', 80.0), + ]) { + testWidgets('sits above the keyboard with $name', (tester) async { + // FakeViewPadding 是物理像素;比例 1 讓數字就是 dp。 + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + tester.view.padding = const FakeViewPadding(bottom: 24); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + final toaster = await pumpHost(tester); + container.read(toastBottomInsetProvider.notifier).set(shell); + + toaster.info('鍵盤之上'); + await tester.pumpAndSettle(); + + expect( + tester.getRect(snackMaterial()).bottom, + 600 - 300 - const AppSpacing().x4, + ); + }); + } + testWidgets('is at most 560 wide and centred on a wide window', ( tester, ) async {