app · 240809e

Dev index Recent changes / versions Changelog About TODO

改進 TTS 過濾頁卡片排版

240809e · 2026-11-11 05:01 · app
Docs/ChangeHistory.mdliveAPP/TTSSettingsView.swift
commit 240809e482aff32207ebefe25d8946cd8cd67779Author: Nuclear0709 <agp05432@gmail.com>Date:   Wed Nov 11 05:01:56 2026 +0800     改進 TTS 過濾頁卡片排版 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex 04629c5..c204456 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -18,6 +18,7 @@ - `AppIcon.appiconset` 原本的 `AppIcon 1.png`/`AppIcon 2.png` 不是不同尺寸,而是與 default 相同 SHA256 的 dark/tinted appearance 變體。已移除兩張重複檔,只保留單張 1024 default;iOS 其餘尺寸由 actool 產生,不再以 legacy 多尺寸重複塞入 catalog。 - GPU 旋轉處理設定頁改為 `AdaptiveSettingsGrid` 卡片:配置、畫布尺寸、方向與縮放三個區塊;寬螢幕並排、窄螢幕單欄,OD/AD 寬高欄位以同一列呈現,減少 iPad 全域捲動。 - 新增 base64 `data:image/...` 圖片來源:聊天頭像/禮物、行內 emoji、贊助橫幅與通知共用本機解碼,不進 URLSession;限制單張 256 KiB、單訊息 768 KiB、4 MP,拒絕 SVG/非 base64/超量,HTTP URL 仍保留給大圖。日誌以長度遮蔽 base64,TTS 與通知使用清洗後文字。+- TTS 過濾頁改為分組卡片:測試與設定/基本清理/排除關鍵字/替換關鍵字;匯入匯出改為圖示按鈕,重複字元選單加上可見標題與說明,並在測試結果為空時明示「會被過濾,不朗讀」。 - 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/liveAPP/TTSSettingsView.swift b/liveAPP/TTSSettingsView.swiftindex cbb237d..56b879b 100644--- a/liveAPP/TTSSettingsView.swift+++ b/liveAPP/TTSSettingsView.swift@@ -422,101 +422,138 @@ private struct SpeechFilterDocumentPicker: UIViewControllerRepresentable {     // 左邊:輸入區     private var inputSection: some View {         VStack(alignment: .leading, spacing: 16) {-            HStack {-                Button("匯入 JSON") {-                    notice = "請選取 JSON 配置檔案。"-                    sendlog(title: "TTS匯入", message: "開啟文件選擇器")-                    importing = true-                }-                .disabled(isReadingImport)-                Button("匯出目前配置") {-                    do {-                        let url = try filter.configuration.write(to: documentsDirectory)-                        exportedURL = url-                        notice = "已儲存至檔案分享目錄 Documents:\(url.lastPathComponent)"-                    } catch { notice = error.localizedDescription }+            GroupBox("測試與設定") {+                VStack(alignment: .leading, spacing: 12) {+                    HStack(spacing: 10) {+                        Button {+                            notice = "請選取 JSON 配置檔案。"+                            sendlog(title: "TTS匯入", message: "開啟文件選擇器")+                            importing = true+                        } label: {+                            Label("匯入 JSON", systemImage: "square.and.arrow.down")+                        }+                        .buttonStyle(.bordered)+                        .disabled(isReadingImport)++                        Button {+                            do {+                                let url = try filter.configuration.write(to: documentsDirectory)+                                exportedURL = url+                                notice = "已儲存至檔案分享目錄 Documents:\(url.lastPathComponent)"+                            } catch { notice = error.localizedDescription }+                        } label: {+                            Label("匯出目前配置", systemImage: "square.and.arrow.up")+                        }+                        .buttonStyle(.bordered)+                    }++                    if isReadingImport { ProgressView("讀取配置中…") }+                    if let notice {+                        Text(notice)+                            .font(.caption)+                            .foregroundStyle(.orange)+                            .fixedSize(horizontal: false, vertical: true)+                    }+                    if let url = exportedURL {+                        ShareLink("分享最近匯出的配置", item: url)+                    }+                    Text("匯出與備份存放在與 log.txt 相同的 Documents 目錄。")+                        .font(.caption).foregroundStyle(.secondary)++                    Divider()++                    Text("訊息測試")+                        .font(.subheadline.weight(.semibold))+                    TextField("輸入訊息測試", text: $inputText)+                        .textFieldStyle(.roundedBorder)++                    if !inputText.isEmpty {+                        VStack(alignment: .leading, spacing: 6) {+                            Text("處理後訊息:")+                                .font(.subheadline)+                                .foregroundColor(.gray)+                            Text(processedText.isEmpty ? "(會被過濾,不朗讀)" : processedText)+                                .padding()+                                .frame(maxWidth: .infinity, alignment: .leading)+                                .background(Color(.secondarySystemBackground))+                                .cornerRadius(8)+                        }+                    }                 }+                .frame(maxWidth: .infinity, alignment: .leading)             }-            if isReadingImport { ProgressView("讀取配置中…") }-            if let notice {-                Text(notice)-                    .font(.caption)-                    .foregroundStyle(.orange)-                    .fixedSize(horizontal: false, vertical: true)-            }-            if let url = exportedURL { ShareLink("分享最近匯出的配置", item: url) }-            Text("匯出與備份存放在與 log.txt 相同的 Documents 目錄。")-                .font(.caption).foregroundStyle(.secondary)-            Text("朗讀過濾設定")-                .font(.headline)-            -            TextField("輸入訊息測試", text: $inputText)-                .textFieldStyle(.roundedBorder)-            -            if !processedText.isEmpty {-                VStack(alignment: .leading, spacing: 8) {-                    Text("處理後訊息:")-                        .font(.subheadline)-                        .foregroundColor(.gray)-                    Text(processedText)-                        .padding()-                        .frame(maxWidth: .infinity, alignment: .leading)-                        .background(Color(.secondarySystemBackground))-                        .cornerRadius(8)++            GroupBox("基本清理") {+                VStack(alignment: .leading, spacing: 12) {+                    Toggle("移除 URL", isOn: $filter.removeURLs)+                    Toggle("移除表情符號 Emoji", isOn: $filter.removeEmoji)+                    Toggle("移除純數字", isOn: $filter.removePureNumbers)++                    Divider()++                    VStack(alignment: .leading, spacing: 6) {+                        Text("重複無意義字串")+                            .font(.subheadline.weight(.semibold))+                        Picker("", selection: $filter.noisePolicy) {+                            Text("不處理").tag(SpeechNoisePolicy.off)+                            Text("整句都是重複字元時跳過").tag(SpeechNoisePolicy.skipWholeMessage)+                            Text("移除重複字元後朗讀").tag(SpeechNoisePolicy.stripNoiseTokens)+                        }+                        .labelsHidden()+                        .pickerStyle(.menu)+                        Text("只處理 ASCII 英數字元重複 3 次以上(wwww、aaaa、bbb);hahaha、abc 與混合正常詞句不會被排除。")+                            .font(.caption).foregroundStyle(.secondary)+                            .fixedSize(horizontal: false, vertical: true)+                    }                 }+                .frame(maxWidth: .infinity, alignment: .leading)             }-            -            Divider()-            -            Toggle("移除 URL", isOn: $filter.removeURLs)-            -            Toggle("移除表情符號 Emoji", isOn: $filter.removeEmoji)-            -            Toggle("移除純數字", isOn: $filter.removePureNumbers)--            Picker("重複無意義字串", selection: $filter.noisePolicy) {-                Text("不處理").tag(SpeechNoisePolicy.off)-                Text("整句都是重複字元時跳過").tag(SpeechNoisePolicy.skipWholeMessage)-                Text("移除重複字元後朗讀").tag(SpeechNoisePolicy.stripNoiseTokens)-            }-            .pickerStyle(.menu)-            Text("只處理 ASCII 英數字元重複 3 次以上(wwww、aaaa、bbb);hahaha、abc 與混合正常詞句不會被排除。")-                .font(.caption).foregroundStyle(.secondary) -            VStack(alignment: .leading) {-                Text("排除關鍵字")-                HStack {-                    TextField("新增排除字", text: $newBlockWord)-                        .textFieldStyle(.roundedBorder)-                    Button("加入") {-                        if !newBlockWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !filter.blockKeywords.contains(newBlockWord) {-                            filter.blockKeywords.append(newBlockWord)-                            newBlockWord = ""+            GroupBox("排除關鍵字") {+                VStack(alignment: .leading, spacing: 8) {+                    HStack(spacing: 8) {+                        TextField("新增排除字", text: $newBlockWord)+                            .textFieldStyle(.roundedBorder)+                        Button("加入") {+                            if !newBlockWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !filter.blockKeywords.contains(newBlockWord) {+                                filter.blockKeywords.append(newBlockWord)+                                newBlockWord = ""+                            }                         }+                        .buttonStyle(.bordered)                     }+                    Text("排除字清單與啟用狀態在右側;點擊列可編輯。")+                        .font(.caption).foregroundStyle(.secondary)                 }+                .frame(maxWidth: .infinity, alignment: .leading)             }-            -            VStack(alignment: .leading) {-                Text("替換關鍵字")-                HStack {-                    TextField("原字", text: $newReplaceWord)-                        .textFieldStyle(.roundedBorder)-                    TextField("替換字", text: $newReplacement)-                        .textFieldStyle(.roundedBorder)-                        .frame(width: 60)-                    Button("加入") {-                        if !newReplaceWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {-                            let isNew = filter.replaceKeywords[newReplaceWord] == nil-                            filter.replaceKeywords[newReplaceWord] = newReplacement-                            if isNew { filter.replacementOrder = filter.orderedReplacementKeys.filter { $0 != newReplaceWord } + [newReplaceWord] }-                            newReplaceWord = ""-                            newReplacement = "B"++            GroupBox("替換關鍵字") {+                VStack(alignment: .leading, spacing: 8) {+                    HStack(spacing: 8) {+                        TextField("原字", text: $newReplaceWord)+                            .textFieldStyle(.roundedBorder)+                        TextField("替換字", text: $newReplacement)+                            .textFieldStyle(.roundedBorder)+                            .frame(width: 60)+                        Button("加入") {+                            if !newReplaceWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {+                                let isNew = filter.replaceKeywords[newReplaceWord] == nil+                                filter.replaceKeywords[newReplaceWord] = newReplacement+                                if isNew { filter.replacementOrder = filter.orderedReplacementKeys.filter { $0 != newReplaceWord } + [newReplaceWord] }+                                newReplaceWord = ""+                                newReplacement = "B"+                            }                         }+                        .buttonStyle(.bordered)                     }+                    Text("替換字清單與啟用狀態在右側;點擊列可編輯。")+                        .font(.caption).foregroundStyle(.secondary)                 }+                .frame(maxWidth: .infinity, alignment: .leading)             }         }+        .frame(maxWidth: .infinity, alignment: .leading)     }          // 右邊:列表區