app · 7e571e1

Dev index Recent changes / versions Changelog About TODO

改善診斷更新時間與健康狀態顏色

7e571e1 · 2026-11-11 21:53 · app
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.mdindex c204456..af7fdc9 100644--- 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.swiftnew file mode 100644index 0000000..38642fe--- /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.swiftindex dbced2f..1582d22 100644--- 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.swiftindex 83dc902..99b409a 100644--- 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.swiftindex 316440c..b6bb7d9 100644--- 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.swiftnew file mode 100644index 0000000..1e66096--- /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) == "剛剛")+    }+}