修正通知中心造成的圖表與 PiP 停滯
Docs/ChangeHistory.mdTODO.mdliveAPP/FileLogView.swiftliveAPP/OtherView.swiftliveAPP/PIPService.swift
commit 55140b18a754c30cbf8e1296d2bbe622daff3e6fAuthor: Nuclear0709 <agp05432@gmail.com>Date: Wed Nov 11 21:55:52 2026 +0800 修正通知中心造成的圖表與 PiP 停滯 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -20,6 +20,7 @@ - 新增 base64 `data:image/...` 圖片來源:聊天頭像/禮物、行內 emoji、贊助橫幅與通知共用本機解碼,不進 URLSession;限制單張 256 KiB、單訊息 768 KiB、4 MP,拒絕 SVG/非 base64/超量,HTTP URL 仍保留給大圖。日誌以長度遮蔽 base64,TTS 與通知使用清洗後文字。 - TTS 過濾頁改為分組卡片:測試與設定/基本清理/排除關鍵字/替換關鍵字;匯入匯出改為圖示按鈕,重複字元選單加上可見標題與說明,並在測試結果為空時明示「會被過濾,不朗讀」。 - 診斷新鮮度不再以 `Int(age)` 截成 `0 秒`:新增 `DiagnosticAge`,顯示「剛剛/0.4 秒/8.6 秒/12 秒/1 分 15 秒」;`CapturePipelineStatus`、StreamDiagnostics 與 ReplayKit 新鮮度改用實際目前時間計算,避免 `sampleDate` 掩蓋過期資料。串流摘要的 health 也改為獨立顏色(healthy 綠、failure 紅、其餘橘/灰)。+- 通知中心/控制中心造成的 `scenePhase=.inactive` 不再停止 DeviceView/FileLogView 的週期更新,只有真正 `.background` 才停止;避免下拉面板時圖表與日誌頁看起來卡住。PiP 回到 `.active` 時會 flush 可能失效或空白的 `AVSampleBufferDisplayLayer` 並重畫;render 遇到 `.failed` 先 flush 再 enqueue,降低 PiP 黑畫面卡住的機率。 - ReplayKit 的 `StreamDiagnosticsSnapshot` 現在也會帶 `audioTracks`;主 App 的「Audio Pipeline · 混音輸出」在 ReplayKit 與 ScreenCaptureKit 都能看到 App/Mic 的 AVCon、ring buffer、align 與 RMS。轉換邏輯集中在 `StreamDiagnosticsSnapshot.audioTrackDiagnostics(from:)`,避免兩條路徑各寫一份。 - `[IdleResources]` 新增 `warmIdleBaselineMB`/`deltaWarmIdleMB`:每次進入擷取前記錄最近一次已穩定的 idle footprint,包含 DeviceInfo/Settings/Audio/PiP 等 UI 常駐成本;`coldIdleBaselineMB` 保留作冷啟動對照,避免把 100 MB warm idle 誤算成串流增量。 - 修正 DeviceView 與 FileLogView 的週期 task:不再使用 `.task(id:)` 由布林狀態重啟,改成頁面可見且前景時才啟動單一 `Task`,離頁/背景立即取消;避免隱藏分頁或重複 view 持續每秒取樣、刷新日誌而累積記憶體。診斷 log 的 sampling start/stop 會用來確認沒有重複 task。diff --git a/TODO.md b/TODO.md--- a/TODO.md+++ b/TODO.md@@ -54,6 +54,7 @@ API 遷移屬後續技術評估,不作為以上功能完成的條件;需要 - [x] 設備頁顯示實體介面上行/下行差分,註明包含其他 App 流量。 - [x] **ScreenCaptureKit 混合輸出圖表實機驗收**:使用者提供 2026-10-20 15:50 設備頁截圖,Video/Audio Pipeline 均顯示 ScreenCaptureKit、歷史曲線與輸出速率;兩張截圖的更新時間與影片 FPS 有變化,本機來源佇列及混音摘要亦可見。 - [ ] **其餘管線圖表驗收**:ReplayKit 圖表、停止與重啟、本地可控錄製、回前景與介面切換;截圖中的 ReplayKit 尚未收到資料,不作為該來源驗收。+- [ ] **通知中心/控制中心回歸**:下拉與關閉面板後 DeviceView 圖表應持續更新、FileLogView 不卡住;PiP 在回到 active 後不應黑畫面。已改為僅 `.background` 停止週期 task,並在 active 時 flush/重畫 PiP layer;待實機驗證。 - [x] 底層公開編碼後格式快照:從實際壓縮樣本更新影片 codec/寬高/色彩、音訊 codec/取樣率/聲道/封包資訊,定義 session/generation 與重連重設語意。 - [x] App/ReplayKit 接入上述快照,替換「實際編碼輸出格式」的缺口說明;不以設定或文字日誌冒充實測。 - [x] 底層 7ce4304b 前置工作已由使用者於 2026-10-20 確認完成;主 App 兩份鎖定檔均接入該版本。diff --git a/liveAPP/FileLogView.swift b/liveAPP/FileLogView.swift--- a/liveAPP/FileLogView.swift+++ b/liveAPP/FileLogView.swift@@ -280,10 +280,11 @@ struct FileLogView: View { startRefreshIfNeeded() } .onChange(of: scenePhase) { phase in- if phase == .active, isVisible {- startRefreshIfNeeded()- } else {+ // inactive 可能是通知中心/控制中心;只有真正進背景才停止刷新。+ if phase == .background { stopRefresh()+ } else if isVisible {+ startRefreshIfNeeded() } } .onDisappear {@@ -295,7 +296,7 @@ struct FileLogView: View { /// 單一刷新 task;頁面切換或進背景時停止,避免隱藏分頁持續每秒讀取。 private func startRefreshIfNeeded() {- guard refreshTask == nil, isVisible, scenePhase == .active else { return }+ guard refreshTask == nil, isVisible, scenePhase != .background else { return } refreshGeneration &+= 1 let generation = refreshGeneration refreshTask = Task { @MainActor indiff --git a/liveAPP/OtherView.swift b/liveAPP/OtherView.swift--- a/liveAPP/OtherView.swift+++ b/liveAPP/OtherView.swift@@ -609,10 +609,11 @@ struct DeviceView: View { startSamplingIfNeeded() } .onChange(of: scenePhase) { phase in- if phase == .active, isVisible {- startSamplingIfNeeded()- } else {+ // 通知中心/控制中心只會讓 App 進入 inactive;不要因此停掉圖表取樣。+ if phase == .background { stopSampling()+ } else if isVisible {+ startSamplingIfNeeded() } } .onDisappear {@@ -1121,7 +1122,7 @@ struct DeviceView: View { /// 單一採樣 task;避開 `.task(id:)` 在頁面切換時重複啟動。 private func startSamplingIfNeeded() {- guard samplingTask == nil, isVisible, scenePhase == .active else { return }+ guard samplingTask == nil, isVisible, scenePhase != .background else { return } cpuInfo = SystemCPU() diskIO = SystemDiskIO() networkTraffic = NetworkTrafficSampler()diff --git a/liveAPP/PIPService.swift b/liveAPP/PIPService.swift--- a/liveAPP/PIPService.swift+++ b/liveAPP/PIPService.swift@@ -1219,6 +1219,12 @@ final class PIPService: NSObject, ObservableObject, @unchecked Sendable { return false } + if displayLayer.status == .failed {+ // AVSampleBufferDisplayLayer 在 App inactive/系統切換後可能進入 failed;+ // flush 後再 enqueue,否則 isReadyForMoreMediaData 會一直 false 形成黑畫面。+ PIPLogTo("displayLayer failed: \(String(describing: displayLayer.error));flush 後重試")+ displayLayer.flush()+ } guard displayLayer.isReadyForMoreMediaData else { backpressureSkips += 1 decayFPSIfNeeded()@@ -1311,6 +1317,9 @@ final class PIPService: NSObject, ObservableObject, @unchecked Sendable { func appWillEnterForeground() { isInBackground = false+ // 通知中心/控制中心可能讓 sample-buffer layer 留下空白畫面;+ // 回到 active 時清掉舊畫面,再由 forceRender 補一張新幀。+ displayLayer?.flush() reAttachDisplayLayerIfNeeded() if pixelBufferPool == nil && OframeSize.width > 0 && OframeSize.height > 0 {