改善診斷更新時間與健康狀態顏色
Docs/ChangeHistory.mdSharedCapture/DiagnosticAge.swiftliveAPP/Capture/CaptureStreamTelemetry.swiftliveAPP/OtherView.swiftliveAPP/StreamDiagnosticsView.swiftliveAPPTests/DiagnosticAgeTests.swift
commit 7e571e1429a2680e25c18e22da596545d6ea3cb4Author: Nuclear0709 <agp05432@gmail.com>Date: Wed Nov 11 21:53:17 2026 +0800 改善診斷更新時間與健康狀態顏色 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -19,6 +19,7 @@ - GPU 旋轉處理設定頁改為 `AdaptiveSettingsGrid` 卡片:配置、畫布尺寸、方向與縮放三個區塊;寬螢幕並排、窄螢幕單欄,OD/AD 寬高欄位以同一列呈現,減少 iPad 全域捲動。 - 新增 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 紅、其餘橘/灰)。 - 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/SharedCapture/DiagnosticAge.swift b/SharedCapture/DiagnosticAge.swift--- /dev/null+++ b/SharedCapture/DiagnosticAge.swift@@ -0,0 +1,21 @@+import Foundation++/// 診斷時間差的人類可讀格式;保留一秒內的小數,避免全部截成 0 秒。+enum DiagnosticAge {+ static func text(_ seconds: TimeInterval) -> String {+ let age = max(0, seconds)+ if age < 0.1 {+ return "剛剛"+ }+ if age < 10 {+ return String(format: "%.1f 秒", age)+ }+ let total = Int(age)+ if total < 60 {+ return "\(total) 秒"+ }+ let minutes = total / 60+ let remainder = total % 60+ return remainder == 0 ? "\(minutes) 分" : "\(minutes) 分 \(remainder) 秒"+ }+}diff --git a/liveAPP/Capture/CaptureStreamTelemetry.swift b/liveAPP/Capture/CaptureStreamTelemetry.swift--- a/liveAPP/Capture/CaptureStreamTelemetry.swift+++ b/liveAPP/Capture/CaptureStreamTelemetry.swift@@ -84,7 +84,7 @@ struct CapturePipelineStatus: Equatable { } let age = max(0, now.timeIntervalSince(sampledAt)) let prefix = failed ? "推流失敗,擷取仍在運作" : "擷取中"- return Self(text: "\(prefix) · 距更新 \(Int(age)) 秒" + (age > 15 ? " · 資料已過期" : ""), isStale: age > 15)+ return Self(text: "\(prefix) · 距更新 \(DiagnosticAge.text(age))" + (age > 15 ? " · 資料已過期" : ""), isStale: age > 15) } } }diff --git a/liveAPP/OtherView.swift b/liveAPP/OtherView.swift--- a/liveAPP/OtherView.swift+++ b/liveAPP/OtherView.swift@@ -583,7 +583,7 @@ struct DeviceView: View { networkCard systemCard gpuCard- MixerPipelineCard(audio: false, now: sampleDate)+ MixerPipelineCard(audio: false, now: Date()) videoPipelineCard liveActivityCard } trailing: {@@ -593,12 +593,12 @@ struct DeviceView: View { #if os(iOS) metricReportsCard #endif- StreamDiagnosticsSections(now: sampleDate)+ StreamDiagnosticsSections(now: Date()) if capture.streamTelemetry.pipelineSampledAt != nil { capturePipelineCard } avLagCard- MixerPipelineCard(audio: true, now: sampleDate)+ MixerPipelineCard(audio: true, now: Date()) audioPipelineCard } .padding(16)@@ -811,7 +811,7 @@ struct DeviceView: View { /// 設備診斷卡片;沿用頁面採樣與服務狀態。 private var capturePipelineCard: some View { SettingsDashboardCard("ScreenCaptureKit 本機管線") {- CapturePipelineStatusView(sampledAt: capture.streamTelemetry.pipelineSampledAt, now: sampleDate)+ CapturePipelineStatusView(sampledAt: capture.streamTelemetry.pipelineSampledAt, now: Date()) Text(capture.streamTelemetry.sourceQueues ?? "尚未取得來源佇列").font(.caption) Text(capture.streamTelemetry.mixerAudio ?? "尚未取得混音資料").font(.caption) Text("來源與混音計數不代表 RTMP 已送出;共用輸出圖表涵蓋兩種來源,進階健康圖表仍由 ReplayKit 擴展提供。")@@ -1101,8 +1101,8 @@ struct DeviceView: View { private func pipelineFreshness(_ updatedAt: Date?) -> some View { Group { if let updatedAt {- let age = max(0, sampleDate.timeIntervalSince(updatedAt))- Text("來源:ReplayKit 擴展 · 距更新 \(Int(age)) 秒" + (age > 15 ? " · 資料已過期" : ""))+ let age = max(0, Date().timeIntervalSince(updatedAt))+ Text("來源:ReplayKit 擴展 · 距更新 \(DiagnosticAge.text(age))" + (age > 15 ? " · 資料已過期" : "")) .font(.caption).foregroundStyle(age > 15 ? Color.orange : Color.secondary) } else { Text("來源:ReplayKit 擴展 · 尚未收到資料")diff --git a/liveAPP/StreamDiagnosticsView.swift b/liveAPP/StreamDiagnosticsView.swift--- a/liveAPP/StreamDiagnosticsView.swift+++ b/liveAPP/StreamDiagnosticsView.swift@@ -174,7 +174,8 @@ struct StreamDiagnosticsSections: View { } label: { VStack(alignment: .leading, spacing: 3) { Text(source).font(.headline)- Text(summaryLine(value)).font(.caption).foregroundStyle(.secondary)+ summaryLabel(value)+ .font(.caption) .lineLimit(2) } }@@ -190,7 +191,7 @@ struct StreamDiagnosticsSections: View { if value.source == "ScreenCaptureKit" { CapturePipelineStatusView(sampledAt: value.sampledAt, now: now) }- summaryMetric("狀態", "\(value.phase) · 距取樣 \(Int(age)) 秒" + (age > 15 ? " · 已過期" : ""))+ summaryMetric("狀態", "\(value.phase) · 距取樣 \(DiagnosticAge.text(age))" + (age > 15 ? " · 已過期" : "")) summaryMetric("VT 編碼器", value.encoderHealth.map { "\($0.state.rawValue) · pending \($0.pendingFrames.map(String.init) ?? "未知")" } ?? "未提供")@@ -294,10 +295,29 @@ struct StreamDiagnosticsSections: View { } } - private func summaryLine(_ value: StreamDiagnosticsSnapshot) -> String {+ private func summaryLabel(_ value: StreamDiagnosticsSnapshot) -> some View { let codec = value.encoding?.video.map { "\($0.codec) \($0.width)×\($0.height)" } ?? "格式未提供" let health = value.encoderHealth?.state.rawValue ?? "VT 未提供"- return "\(codec) · \(health) · \(value.phase)"+ return HStack(spacing: 4) {+ Text(codec).foregroundStyle(.secondary)+ Text("·").foregroundStyle(.secondary)+ Text(health).foregroundStyle(healthColor(health))+ Text("·").foregroundStyle(.secondary)+ Text(value.phase).foregroundStyle(.secondary)+ }+ }++ private func healthColor(_ health: String) -> Color {+ switch health {+ case "healthy":+ return .green+ case "failure":+ return .red+ case "VT 未提供":+ return .gray+ default:+ return .orange+ } } /// 最新嘗試預設可見;首筆資料保留為起始診斷,不與目前進展混排。diff --git a/liveAPPTests/DiagnosticAgeTests.swift b/liveAPPTests/DiagnosticAgeTests.swift--- /dev/null+++ b/liveAPPTests/DiagnosticAgeTests.swift@@ -0,0 +1,18 @@+import Testing+@testable import liveAPP++struct DiagnosticAgeTests {+ @Test func formatsSubsecondAndSecondsWithoutTruncatingToZero() {+ #expect(DiagnosticAge.text(0) == "剛剛")+ #expect(DiagnosticAge.text(0.04) == "剛剛")+ #expect(DiagnosticAge.text(0.4) == "0.4 秒")+ #expect(DiagnosticAge.text(8.6) == "8.6 秒")+ }++ @Test func formatsSecondsAndMinutes() {+ #expect(DiagnosticAge.text(12.4) == "12 秒")+ #expect(DiagnosticAge.text(60) == "1 分")+ #expect(DiagnosticAge.text(75) == "1 分 15 秒")+ #expect(DiagnosticAge.text(-1) == "剛剛")+ }+}