From 77fc0c29614d0842528c6e8e555635bcdae9fb8e Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Thu, 9 Jul 2026 21:56:47 +1200 Subject: [PATCH 01/23] feat: overlay settings (opacity/fontSize/pinned) + UserDefaults persistence Co-Authored-By: Claude Opus 4.8 --- .../LiveSubtitle/Models/SubtitleModels.swift | 9 +++- .../LiveSubtitle/Models/SubtitleStore.swift | 18 +++++++- .../SubtitleSettingsTests.swift | 43 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 Tests/LiveSubtitleTests/SubtitleSettingsTests.swift diff --git a/Sources/LiveSubtitle/Models/SubtitleModels.swift b/Sources/LiveSubtitle/Models/SubtitleModels.swift index 858e93a..596ce1d 100644 --- a/Sources/LiveSubtitle/Models/SubtitleModels.swift +++ b/Sources/LiveSubtitle/Models/SubtitleModels.swift @@ -1,8 +1,13 @@ import Foundation enum Speaker: Sendable, Equatable { case me, other } // 麦克风=me,系统声=other -enum DisplayMode: Sendable { case originalOnly, both, translatedOnly } -enum OverlayMode: Sendable { case bar, mini } +enum DisplayMode: String, Sendable, CaseIterable { case originalOnly, both, translatedOnly } +enum OverlayMode: String, Sendable, CaseIterable { case bar, mini } + +extension DisplayMode { + var showsOriginal: Bool { self != .translatedOnly } + var showsTranslated: Bool { self != .originalOnly } +} /// 跨 actor 的 Sendable 音频载体(不直接传 AVAudioPCMBuffer,后者非 Sendable)。 /// pcm 为已转换到 analyzer 目标格式(16k/Int16/单声道)的样本。 diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index fb87220..9595bed 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -4,9 +4,14 @@ import Observation @MainActor @Observable final class SubtitleStore { + private let defaults: UserDefaults private(set) var lines: [SubtitleLine] = [] // 全量内存历史(不落盘) - var displayMode: DisplayMode = .both // Phase 1 固定 - var overlayMode: OverlayMode = .bar + + var displayMode: DisplayMode { didSet { defaults.set(displayMode.rawValue, forKey: "ls.displayMode") } } + var overlayMode: OverlayMode { didSet { defaults.set(overlayMode.rawValue, forKey: "ls.overlayMode") } } + var opacity: Double { didSet { defaults.set(opacity, forKey: "ls.opacity") } } + var fontSize: Double { didSet { defaults.set(fontSize, forKey: "ls.fontSize") } } + var pinned: Bool { didSet { defaults.set(pinned, forKey: "ls.pinned") } } /// 每个 speaker 的"当前未定稿灰字行"索引;定稿后清除。 private var volatileIndex: [Speaker: Int] = [:] @@ -14,6 +19,15 @@ final class SubtitleStore { /// 每个 speaker 的暂存中间态,尚未上屏(由节流器 flush)。 private var pendingVolatile: [Speaker: String] = [:] + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + displayMode = DisplayMode(rawValue: defaults.string(forKey: "ls.displayMode") ?? "") ?? .both + overlayMode = OverlayMode(rawValue: defaults.string(forKey: "ls.overlayMode") ?? "") ?? .bar + opacity = defaults.object(forKey: "ls.opacity") as? Double ?? 0.82 + fontSize = defaults.object(forKey: "ls.fontSize") as? Double ?? 22 + pinned = defaults.bool(forKey: "ls.pinned") + } + /// 暂存中间态,不立即上屏(由节流器 flush)。 func stageVolatile(speaker: Speaker, text: String) { pendingVolatile[speaker] = text diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift new file mode 100644 index 0000000..10cf515 --- /dev/null +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import LiveSubtitle + +@MainActor +final class SubtitleSettingsTests: XCTestCase { + private func freshSuite() -> UserDefaults { + UserDefaults(suiteName: "test.ls.\(UUID().uuidString)")! + } + + func testDefaultsWhenEmpty() { + let s = SubtitleStore(defaults: freshSuite()) + XCTAssertEqual(s.displayMode, .both) + XCTAssertEqual(s.overlayMode, .bar) + XCTAssertEqual(s.opacity, 0.82, accuracy: 0.0001) + XCTAssertEqual(s.fontSize, 22, accuracy: 0.0001) + XCTAssertFalse(s.pinned) + } + + func testSettingsPersistAcrossInstances() { + let suite = freshSuite() + let s1 = SubtitleStore(defaults: suite) + s1.displayMode = .translatedOnly + s1.overlayMode = .mini + s1.opacity = 0.5 + s1.fontSize = 28 + s1.pinned = true + let s2 = SubtitleStore(defaults: suite) + XCTAssertEqual(s2.displayMode, .translatedOnly) + XCTAssertEqual(s2.overlayMode, .mini) + XCTAssertEqual(s2.opacity, 0.5, accuracy: 0.0001) + XCTAssertEqual(s2.fontSize, 28, accuracy: 0.0001) + XCTAssertTrue(s2.pinned) + } + + func testDisplayModeHelpers() { + XCTAssertTrue(DisplayMode.both.showsOriginal) + XCTAssertTrue(DisplayMode.both.showsTranslated) + XCTAssertFalse(DisplayMode.translatedOnly.showsOriginal) + XCTAssertTrue(DisplayMode.translatedOnly.showsTranslated) + XCTAssertTrue(DisplayMode.originalOnly.showsOriginal) + XCTAssertFalse(DisplayMode.originalOnly.showsTranslated) + } +} From 3adbb43b17e8ab0307287d47f823b8e7281fdeae Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Thu, 9 Jul 2026 21:58:16 +1200 Subject: [PATCH 02/23] feat: SubtitleBarView respects displayMode/opacity/fontSize; extract SubtitleLineRow --- .../Overlay/SubtitleBarView.swift | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift index 28fe3f0..92b524a 100644 --- a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift +++ b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift @@ -1,33 +1,47 @@ import SwiftUI +/// 单行渲染,bar 与 mini 复用。读 displayMode/fontSize。 +struct SubtitleLineRow: View { + let line: SubtitleLine + let displayMode: DisplayMode + let fontSize: Double + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text(line.speaker == .me ? "我" : "对方") + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(line.speaker == .me ? Color.blue : Color.orange) + .clipShape(RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 3) { + if displayMode.showsOriginal { + Text(line.original) + .font(.system(size: fontSize * 0.68)) + .foregroundStyle(.white.opacity(line.isFinal ? 0.6 : 0.4)) + .italic(!line.isFinal) + } + if displayMode.showsTranslated { + if let zh = line.translated { + Text(zh).font(.system(size: fontSize, weight: .medium)).foregroundStyle(.white) + } else if line.isFinal { + Text("翻译中…").font(.system(size: fontSize * 0.64)).foregroundStyle(.white.opacity(0.3)) + } + } + } + } + } +} + struct SubtitleBarView: View { var store: SubtitleStore var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(store.lines.suffix(3)) { line in - HStack(alignment: .top, spacing: 10) { - Text(line.speaker == .me ? "我" : "对方") - .font(.system(size: 12, weight: .medium)) - .padding(.horizontal, 7).padding(.vertical, 2) - .background(line.speaker == .me ? Color.blue : Color.orange) - .clipShape(RoundedRectangle(cornerRadius: 6)) - VStack(alignment: .leading, spacing: 3) { - Text(line.original) - .font(.system(size: 15)) - .foregroundStyle(.white.opacity(line.isFinal ? 0.6 : 0.4)) - .italic(!line.isFinal) - if let zh = line.translated { - Text(zh).font(.system(size: 22, weight: .medium)).foregroundStyle(.white) - } else if line.isFinal { - Text("翻译中…").font(.system(size: 14)).foregroundStyle(.white.opacity(0.3)) - } - } - } + SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) } } .padding(18) .frame(width: 900, alignment: .leading) - .background(.black.opacity(0.82), in: RoundedRectangle(cornerRadius: 18)) + .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18).stroke(.white.opacity(0.1))) } } From bc7792beeae75ed1b3ffe081f857da60ccb686c2 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Thu, 9 Jul 2026 21:59:39 +1200 Subject: [PATCH 03/23] feat: MiniWindowView scrollable history with auto-scroll-to-bottom --- .../LiveSubtitle/Overlay/MiniWindowView.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Sources/LiveSubtitle/Overlay/MiniWindowView.swift diff --git a/Sources/LiveSubtitle/Overlay/MiniWindowView.swift b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift new file mode 100644 index 0000000..d2c0705 --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// 小窗:全部历史 + 竖向滚动,新句自动滚到底。复用 SubtitleLineRow。 +struct MiniWindowView: View { + var store: SubtitleStore + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(store.lines) { line in + SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) + .id(line.id) + } + Color.clear.frame(height: 1).id("bottom") + } + .padding(14) + } + .onChange(of: store.lines.count) { + withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("bottom", anchor: .bottom) } + } + } + .frame(width: 380, height: 480) + .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(.white.opacity(0.12))) + } +} From 58e309a9991dfa12fc0402a35271c70977a158f2 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Thu, 9 Jul 2026 22:02:14 +1200 Subject: [PATCH 04/23] feat: OverlayController bar/mini switch, Pin window level, draggable mini with saved position Co-Authored-By: Claude Opus 4.8 --- .../Overlay/OverlayController.swift | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 373a7b9..3ca660a 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -4,28 +4,96 @@ import SwiftUI @MainActor final class OverlayController { private var panel: NSPanel? + private var store: SubtitleStore? + private var moveObserver: NSObjectProtocol? + private let defaults = UserDefaults.standard func show(store: SubtitleStore) { + self.store = store + applyMode() + observe() + } + + func hide() { + removeMoveObserver() + panel?.orderOut(nil) + panel = nil + } + + /// 观察 overlayMode / pinned,变化即重配并重新武装观察。 + private func observe() { + guard let store else { return } + withObservationTracking { + _ = store.overlayMode + _ = store.pinned + } onChange: { + Task { @MainActor in + self.applyMode() + self.observe() + } + } + } + + private func applyMode() { + guard let store else { return } + removeMoveObserver() + panel?.orderOut(nil) + let p = (store.overlayMode == .bar) ? makeBarPanel(store) : makeMiniPanel(store) + p.level = store.pinned ? .screenSaver : .floating + p.orderFrontRegardless() + panel = p + } + + private func makeBarPanel(_ store: SubtitleStore) -> NSPanel { let host = NSHostingView(rootView: SubtitleBarView(store: store)) host.frame = NSRect(x: 0, y: 0, width: 900, height: 200) - let p = NSPanel(contentRect: host.frame, - styleMask: [.nonactivatingPanel, .borderless], + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) p.isFloatingPanel = true - p.level = .floating p.backgroundColor = .clear p.isOpaque = false p.hasShadow = true - p.becomesKeyOnlyIfNeeded = true + p.ignoresMouseEvents = true // 字幕条点击穿透 p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host if let screen = NSScreen.main { let f = screen.visibleFrame p.setFrameOrigin(NSPoint(x: f.midX - 450, y: f.minY + 60)) } - p.orderFrontRegardless() - panel = p + return p } - func hide() { panel?.orderOut(nil); panel = nil } + private func makeMiniPanel(_ store: SubtitleStore) -> NSPanel { + let host = NSHostingView(rootView: MiniWindowView(store: store)) + host.frame = NSRect(x: 0, y: 0, width: 380, height: 480) + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, defer: false) + p.isFloatingPanel = true + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = true + p.isMovableByWindowBackground = true // 小窗可拖 + p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + p.contentView = host + if let x = defaults.object(forKey: "ls.miniX") as? Double, + let y = defaults.object(forKey: "ls.miniY") as? Double { + p.setFrameOrigin(NSPoint(x: x, y: y)) + } else if let screen = NSScreen.main { + let f = screen.visibleFrame + p.setFrameOrigin(NSPoint(x: f.maxX - 400, y: f.minY + 80)) + } + moveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let origin = self.panel?.frame.origin else { return } + self.defaults.set(Double(origin.x), forKey: "ls.miniX") + self.defaults.set(Double(origin.y), forKey: "ls.miniY") + } + } + return p + } + + private func removeMoveObserver() { + if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } + } } From c45ba94ad02f948cecf2cdbf1de8f84a621fe7cf Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Thu, 9 Jul 2026 22:04:24 +1200 Subject: [PATCH 05/23] feat: menu controls for display mode / overlay form / Pin / opacity / font size --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 1bcbc81..eeedf77 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -11,9 +11,29 @@ struct LiveSubtitleApp: App { var body: some Scene { MenuBarExtra("LiveSubtitle", systemImage: "captions.bubble") { + @Bindable var s = store Button(running ? "停止字幕" : "开始字幕") { toggle() } if !status.isEmpty { Text(status).font(.caption) } Divider() + + Picker("显示", selection: $s.displayMode) { + Text("原文").tag(DisplayMode.originalOnly) + Text("双语").tag(DisplayMode.both) + Text("译文").tag(DisplayMode.translatedOnly) + } + Picker("形态", selection: $s.overlayMode) { + Text("字幕条").tag(OverlayMode.bar) + Text("小窗").tag(OverlayMode.mini) + } + Toggle("置顶 Pin", isOn: $s.pinned) + Divider() + + Text("透明度") + Slider(value: $s.opacity, in: 0.4...1.0) + Text("字号") + Slider(value: $s.fontSize, in: 16...32, step: 1) + Divider() + Button("退出") { NSApplication.shared.terminate(nil) } } } From 1e6f1e9ce58e42e11549aef606c98561e43a3c46 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Mon, 13 Jul 2026 23:37:54 +1200 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20settings=20model=20=E2=80=94=20ba?= =?UTF-8?q?rWidth/layoutEditing=20+=20DeepSeek=20key=20+=20Obsidian=20vaul?= =?UTF-8?q?t=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit barWidth/deepSeekAPIKey/obsidianVaultPath persisted via UserDefaults (didSet); layoutEditing is transient (never persists, always false on launch). Adds 4 SubtitleSettingsTests covering defaults + persistence + transience. Co-Authored-By: Claude Opus 4.8 --- .../LiveSubtitle/Models/SubtitleStore.swift | 10 ++++++++++ .../SubtitleSettingsTests.swift | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index 9595bed..46dc0d2 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -12,6 +12,12 @@ final class SubtitleStore { var opacity: Double { didSet { defaults.set(opacity, forKey: "ls.opacity") } } var fontSize: Double { didSet { defaults.set(fontSize, forKey: "ls.fontSize") } } var pinned: Bool { didSet { defaults.set(pinned, forKey: "ls.pinned") } } + var barWidth: Double { didSet { defaults.set(barWidth, forKey: "ls.barWidth") } } + var deepSeekAPIKey: String { didSet { defaults.set(deepSeekAPIKey, forKey: "ls.deepSeekKey") } } + var obsidianVaultPath: String { didSet { defaults.set(obsidianVaultPath, forKey: "ls.vaultPath") } } + + /// 布局编辑态,瞬态(不持久化),启动永远 false。 + var layoutEditing: Bool = false /// 每个 speaker 的"当前未定稿灰字行"索引;定稿后清除。 private var volatileIndex: [Speaker: Int] = [:] @@ -26,6 +32,10 @@ final class SubtitleStore { opacity = defaults.object(forKey: "ls.opacity") as? Double ?? 0.82 fontSize = defaults.object(forKey: "ls.fontSize") as? Double ?? 22 pinned = defaults.bool(forKey: "ls.pinned") + barWidth = defaults.object(forKey: "ls.barWidth") as? Double ?? 900 + deepSeekAPIKey = defaults.string(forKey: "ls.deepSeekKey") ?? "" + obsidianVaultPath = defaults.string(forKey: "ls.vaultPath") ?? "" + layoutEditing = false } /// 暂存中间态,不立即上屏(由节流器 flush)。 diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift index 10cf515..78d3b19 100644 --- a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -14,6 +14,10 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s.opacity, 0.82, accuracy: 0.0001) XCTAssertEqual(s.fontSize, 22, accuracy: 0.0001) XCTAssertFalse(s.pinned) + XCTAssertEqual(s.barWidth, 900, accuracy: 0.0001) + XCTAssertEqual(s.deepSeekAPIKey, "") + XCTAssertEqual(s.obsidianVaultPath, "") + XCTAssertFalse(s.layoutEditing) } func testSettingsPersistAcrossInstances() { @@ -24,12 +28,26 @@ final class SubtitleSettingsTests: XCTestCase { s1.opacity = 0.5 s1.fontSize = 28 s1.pinned = true + s1.barWidth = 1200 + s1.deepSeekAPIKey = "sk-test-123" + s1.obsidianVaultPath = "/Users/me/Vault" let s2 = SubtitleStore(defaults: suite) XCTAssertEqual(s2.displayMode, .translatedOnly) XCTAssertEqual(s2.overlayMode, .mini) XCTAssertEqual(s2.opacity, 0.5, accuracy: 0.0001) XCTAssertEqual(s2.fontSize, 28, accuracy: 0.0001) XCTAssertTrue(s2.pinned) + XCTAssertEqual(s2.barWidth, 1200, accuracy: 0.0001) + XCTAssertEqual(s2.deepSeekAPIKey, "sk-test-123") + XCTAssertEqual(s2.obsidianVaultPath, "/Users/me/Vault") + } + + func testLayoutEditingIsTransient() { + let suite = freshSuite() + let s1 = SubtitleStore(defaults: suite) + s1.layoutEditing = true + let s2 = SubtitleStore(defaults: suite) + XCTAssertFalse(s2.layoutEditing) } func testDisplayModeHelpers() { From b0b84afd714d61b13b6f273e7a02aa8cf431c535 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Mon, 13 Jul 2026 23:37:54 +1200 Subject: [PATCH 07/23] feat(phase4): launch-time permission preflight + draggable/resizable subtitle bar PermissionsManager requests mic + screen-recording on launch. Bar becomes interactive + movable when layoutEditing is on (else click-through); bar origin saved to ls.barX/Y like mini; bar width reads store.barWidth. OverlayController observes barWidth/layoutEditing and re-applies. Co-Authored-By: Claude Opus 4.8 --- .../Overlay/OverlayController.swift | 26 +++++++++++++++---- .../Overlay/PermissionsManager.swift | 23 ++++++++++++++++ .../Overlay/SubtitleBarView.swift | 12 +++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 Sources/LiveSubtitle/Overlay/PermissionsManager.swift diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 3ca660a..1053991 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -20,12 +20,14 @@ final class OverlayController { panel = nil } - /// 观察 overlayMode / pinned,变化即重配并重新武装观察。 + /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 private func observe() { guard let store else { return } withObservationTracking { _ = store.overlayMode _ = store.pinned + _ = store.barWidth + _ = store.layoutEditing } onChange: { Task { @MainActor in self.applyMode() @@ -45,20 +47,34 @@ final class OverlayController { } private func makeBarPanel(_ store: SubtitleStore) -> NSPanel { + let width = store.barWidth let host = NSHostingView(rootView: SubtitleBarView(store: store)) - host.frame = NSRect(x: 0, y: 0, width: 900, height: 200) + host.frame = NSRect(x: 0, y: 0, width: width, height: 200) let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) p.isFloatingPanel = true p.backgroundColor = .clear p.isOpaque = false p.hasShadow = true - p.ignoresMouseEvents = true // 字幕条点击穿透 + // 布局编辑态:字幕条可交互 + 可拖;否则点击穿透。 + p.ignoresMouseEvents = !store.layoutEditing + p.isMovableByWindowBackground = store.layoutEditing p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host - if let screen = NSScreen.main { + if let x = defaults.object(forKey: "ls.barX") as? Double, + let y = defaults.object(forKey: "ls.barY") as? Double { + p.setFrameOrigin(NSPoint(x: x, y: y)) + } else if let screen = NSScreen.main { let f = screen.visibleFrame - p.setFrameOrigin(NSPoint(x: f.midX - 450, y: f.minY + 60)) + p.setFrameOrigin(NSPoint(x: f.midX - width / 2, y: f.minY + 60)) + } + moveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let origin = self.panel?.frame.origin else { return } + self.defaults.set(Double(origin.x), forKey: "ls.barX") + self.defaults.set(Double(origin.y), forKey: "ls.barY") + } } return p } diff --git a/Sources/LiveSubtitle/Overlay/PermissionsManager.swift b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift new file mode 100644 index 0000000..850d65e --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift @@ -0,0 +1,23 @@ +import Foundation +import AVFoundation +import CoreGraphics + +/// 前置权限申请:app 启动时一次性请求麦克风 + 屏幕录制授权。 +/// 屏幕录制授权是系统声音采集(ScreenCaptureKit)所需的前提。 +@MainActor +enum PermissionsManager { + /// 供 App 启动时调用。非阻塞:两项权限各自异步触发系统授权框,忽略结果。 + static func requestAllOnLaunch() { + // ① 麦克风:异步弹权限框,不关心结果。 + AVCaptureDevice.requestAccess(for: .audio) { _ in } + + // ② 屏幕录制(系统音频采集依赖此授权)。 + // 仅在尚未授权时触发系统授权引导。CGRequestScreenCaptureAccess 是同步阻塞的 + // C API,放到后台队列执行以免阻塞主线程弹窗时机;它只触发 TCC,无需回主线程更新 UI。 + if CGPreflightScreenCaptureAccess() == false { + DispatchQueue.global().async { + _ = CGRequestScreenCaptureAccess() + } + } + } +} diff --git a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift index 92b524a..782c9e3 100644 --- a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift +++ b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift @@ -40,8 +40,16 @@ struct SubtitleBarView: View { } } .padding(18) - .frame(width: 900, alignment: .leading) + .frame(width: store.barWidth, alignment: .leading) .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 18)) - .overlay(RoundedRectangle(cornerRadius: 18).stroke(.white.opacity(0.1))) + .overlay( + RoundedRectangle(cornerRadius: 18) + .strokeBorder( + store.layoutEditing ? Color.yellow.opacity(0.9) : Color.white.opacity(0.1), + style: store.layoutEditing + ? StrokeStyle(lineWidth: 2.5, dash: [8, 5]) + : StrokeStyle(lineWidth: 1) + ) + ) } } From e9b7069c0ebb9b525aff5fde6bde8bd595361a31 Mon Sep 17 00:00:00 2001 From: zhangchaolong Date: Mon, 13 Jul 2026 23:37:54 +1200 Subject: [PATCH 08/23] feat(phase5): Obsidian .md export + DeepSeek summary + settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObsidianExporter writes -.md (frontmatter + summary + transcript) to the vault folder. DeepSeekClient calls the OpenAI-compatible chat API for a JSON {title,summary}. ExportCoordinator orchestrates: gather final lines → DeepSeek (fallback if no key/failure) → write. SettingsView configures key + vault. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../LiveSubtitle/Export/DeepSeekClient.swift | 114 +++++++++++++++ .../Export/ExportCoordinator.swift | 61 ++++++++ .../Export/ObsidianExporter.swift | 133 ++++++++++++++++++ .../LiveSubtitle/Overlay/SettingsView.swift | 50 +++++++ 4 files changed, 358 insertions(+) create mode 100644 Sources/LiveSubtitle/Export/DeepSeekClient.swift create mode 100644 Sources/LiveSubtitle/Export/ExportCoordinator.swift create mode 100644 Sources/LiveSubtitle/Export/ObsidianExporter.swift create mode 100644 Sources/LiveSubtitle/Overlay/SettingsView.swift diff --git a/Sources/LiveSubtitle/Export/DeepSeekClient.swift b/Sources/LiveSubtitle/Export/DeepSeekClient.swift new file mode 100644 index 0000000..8992944 --- /dev/null +++ b/Sources/LiveSubtitle/Export/DeepSeekClient.swift @@ -0,0 +1,114 @@ +import Foundation + +/// 调 DeepSeek 云 API(OpenAI 兼容)对转录做总结+优化,并自动生成 title。 +struct DeepSeekClient { + let apiKey: String + + struct Result: Sendable { + let title: String + let summary: String + } + + enum DeepSeekError: Error { + case missingKey + case http(Int, String) + case badResponse(String) + } + + private static let endpoint = URL(string: "https://api.deepseek.com/chat/completions")! + + private static let systemPrompt = """ + 你是中文会议纪要助手。你会阅读一段中英混合的实时字幕转录,输出简洁准确的中文纪要,\ + 并为这段内容起一个不超过20字的标题。\ + 你必须只返回一个 JSON 对象,格式为 {"title":"...","summary":"..."},\ + 其中 summary 使用 markdown 要点(bullet points)形式,不要输出 JSON 以外的任何内容。 + """ + + // MARK: - Request/Response Codable + + private struct RequestBody: Encodable { + struct Message: Encodable { + let role: String + let content: String + } + struct ResponseFormat: Encodable { + let type: String + } + let model: String + let messages: [Message] + let temperature: Double + let response_format: ResponseFormat + } + + private struct ChatCompletionResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + let content: String + } + let message: Message + } + let choices: [Choice] + } + + private struct SummaryPayload: Decodable { + let title: String + let summary: String + } + + /// 传入整段转录(中英混合),返回 { title, summary }。summary 为中文纪要式总结/优化。 + func summarize(transcript: String) async throws -> Result { + guard !apiKey.isEmpty else { throw DeepSeekError.missingKey } + + let body = RequestBody( + model: "deepseek-chat", + messages: [ + RequestBody.Message(role: "system", content: Self.systemPrompt), + RequestBody.Message(role: "user", content: transcript), + ], + temperature: 0.3, + response_format: RequestBody.ResponseFormat(type: "json_object") + ) + + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw DeepSeekError.badResponse("非 HTTP 响应") + } + guard http.statusCode == 200 else { + let bodyString = String(data: data, encoding: .utf8) ?? "" + throw DeepSeekError.http(http.statusCode, bodyString) + } + + let decoder = JSONDecoder() + let completion: ChatCompletionResponse + do { + completion = try decoder.decode(ChatCompletionResponse.self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw DeepSeekError.badResponse("顶层响应解析失败: \(raw)") + } + + guard let content = completion.choices.first?.message.content else { + throw DeepSeekError.badResponse("choices 为空") + } + + guard let contentData = content.data(using: .utf8) else { + throw DeepSeekError.badResponse("content 无法转为数据: \(content)") + } + + let payload: SummaryPayload + do { + payload = try decoder.decode(SummaryPayload.self, from: contentData) + } catch { + throw DeepSeekError.badResponse("content JSON 解析失败: \(content)") + } + + return Result(title: payload.title, summary: payload.summary) + } +} diff --git a/Sources/LiveSubtitle/Export/ExportCoordinator.swift b/Sources/LiveSubtitle/Export/ExportCoordinator.swift new file mode 100644 index 0000000..9b25252 --- /dev/null +++ b/Sources/LiveSubtitle/Export/ExportCoordinator.swift @@ -0,0 +1,61 @@ +import Foundation + +/// 把当前会话的转录整理(可选 DeepSeek 总结)后写入 Obsidian vault。 +/// 独立于 CaptionEngine/toggle 逻辑,仅在菜单“整理并导出到 Obsidian”触发。 +@MainActor +enum ExportCoordinator { + + /// 执行一次导出,返回给 UI 显示的中文状态文本。 + static func exportToObsidian(store: SubtitleStore) async -> String { + let finalLines = store.lines.filter { $0.isFinal } + guard !finalLines.isEmpty else { + return "没有可导出的转录" + } + + let transcript = ObsidianExporter.transcriptMarkdown(from: finalLines) + let apiKey = store.deepSeekAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + let vaultPath = store.obsidianVaultPath + let now = Date() + + // ① 总结:有 key 就调 DeepSeek,失败/无 key 回退到默认 title/summary。 + let title: String + let summary: String + if !apiKey.isEmpty { + do { + let result = try await DeepSeekClient(apiKey: apiKey).summarize(transcript: transcript) + title = result.title + summary = result.summary + } catch { + title = fallbackTitle(now) + summary = "(DeepSeek 总结失败,已跳过)" + } + } else { + title = fallbackTitle(now) + summary = "(未配置 DeepSeek,略过总结)" + } + + // ② 写盘:后台执行,避免阻塞主线程。 + let note = ObsidianExporter.Note( + title: title, + summary: summary, + transcriptMarkdown: transcript, + date: now + ) + do { + let url = try await Task.detached { try ObsidianExporter.write(note, toVaultPath: vaultPath) }.value + return "已导出: \(url.lastPathComponent)" + } catch let error as LocalizedError { + return error.errorDescription ?? "导出失败" + } catch { + return "导出失败: \(error.localizedDescription)" + } + } + + /// 无 DeepSeek 时的回退标题:只用时刻(日期已在文件名前缀里,避免重复)。 + private static func fallbackTitle(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } +} diff --git a/Sources/LiveSubtitle/Export/ObsidianExporter.swift b/Sources/LiveSubtitle/Export/ObsidianExporter.swift new file mode 100644 index 0000000..d2918a0 --- /dev/null +++ b/Sources/LiveSubtitle/Export/ObsidianExporter.swift @@ -0,0 +1,133 @@ +import Foundation + +/// 把一次会话的转录 + DeepSeek 总结导出为一个 Obsidian vault 里的 .md 文件。 +enum ObsidianExporter { + + struct Note { + let title: String + let summary: String + let transcriptMarkdown: String + let date: Date + } + + enum ExportError: LocalizedError { + case emptyVaultPath + case vaultNotFound(String) + + var errorDescription: String? { + switch self { + case .emptyVaultPath: + return "未设置 Obsidian vault 路径" + case .vaultNotFound(let path): + return "Obsidian vault 目录不存在:\(path)" + } + } + } + + /// 从字幕行生成转录 markdown(只收 isFinal 的行)。 + /// 每行: "- **我/对方**:原文" 若有译文再 " — 译文"。 + static func transcriptMarkdown(from lines: [SubtitleLine]) -> String { + lines + .filter { $0.isFinal } + .map { line in + let speaker = displayName(for: line.speaker) + var row = "- **\(speaker)**:\(line.original)" + if let translated = line.translated, + !translated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + row += " — \(translated)" + } + return row + } + .joined(separator: "\n") + } + + /// 写入 <vaultPath>/<yyyy-MM-dd>-<sanitized title>.md,返回写入的 URL。 + @discardableResult + static func write(_ note: Note, toVaultPath vaultPath: String) throws -> URL { + let trimmedVault = vaultPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedVault.isEmpty else { + throw ExportError.emptyVaultPath + } + + let vaultURL = URL(fileURLWithPath: trimmedVault, isDirectory: true) + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: vaultURL.path, isDirectory: &isDirectory) + guard exists, isDirectory.boolValue else { + throw ExportError.vaultNotFound(trimmedVault) + } + + let fileName = "\(fileDateString(note.date))-\(sanitize(note.title)).md" + let fileURL = vaultURL.appendingPathComponent(fileName, isDirectory: false) + + let content = fileContent(for: note) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + // MARK: - Helpers + + private static func displayName(for speaker: Speaker) -> String { + switch speaker { + case .me: return "我" + case .other: return "对方" + } + } + + private static func sanitize(_ title: String) -> String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "未命名" } + + let illegal: Set<Character> = ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"] + var cleaned = String(trimmed.map { illegal.contains($0) ? "-" : $0 }) + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + if cleaned.count > 60 { + cleaned = String(cleaned.prefix(60)) + } + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? "未命名" : cleaned + } + + private static func fileDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func frontmatterDateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } + + private static func fileContent(for note: Note) -> String { + let title = note.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "未命名" + : note.title.trimmingCharacters(in: .whitespacesAndNewlines) + + var out = "---\n" + out += "title: \(yamlScalar(title))\n" + out += "date: \(frontmatterDateString(note.date))\n" + out += "tags: [livesubtitle]\n" + out += "source: LiveSubtitle\n" + out += "---\n" + out += "\n## 总结\n\n" + out += note.summary + out += "\n\n## 转录\n\n" + out += note.transcriptMarkdown + return out + } + + /// 对 YAML scalar 做最小转义:含特殊字符时用双引号包裹并转义内部引号/反斜杠。 + private static func yamlScalar(_ value: String) -> String { + let needsQuoting = value.contains(where: { ":#\"'[]{}".contains($0) }) + || value.hasPrefix(" ") || value.hasSuffix(" ") + guard needsQuoting else { return value } + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } +} diff --git a/Sources/LiveSubtitle/Overlay/SettingsView.swift b/Sources/LiveSubtitle/Overlay/SettingsView.swift new file mode 100644 index 0000000..7165768 --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/SettingsView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import AppKit + +/// 设置页:DeepSeek API Key + Obsidian vault 路径选择。 +@MainActor +struct SettingsView: View { + @Bindable var store: SubtitleStore + + var body: some View { + Form { + Section("DeepSeek") { + SecureField("DeepSeek API Key", text: $store.deepSeekAPIKey) + .textFieldStyle(.roundedBorder) + } + + Section("Obsidian") { + HStack(alignment: .firstTextBaseline) { + Text("Vault 路径") + Spacer() + Text(vaultDisplayPath) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(store.obsidianVaultPath) + } + Button("选择文件夹…") { chooseVaultFolder() } + } + } + .formStyle(.grouped) + .padding() + .frame(width: 420) + } + + private var vaultDisplayPath: String { + let trimmed = store.obsidianVaultPath.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "未选择" : trimmed + } + + private func chooseVaultFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "选择" + panel.message = "选择 Obsidian vault 文件夹" + if panel.runModal() == .OK, let url = panel.url { + store.obsidianVaultPath = url.path + } + } +} From 7714802545b8960f1759b4506124478716fcebc1 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Mon, 13 Jul 2026 23:37:54 +1200 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20wire=20Phase=204/5=20into=20app?= =?UTF-8?q?=20=E2=80=94=20menu=20bar/edit=20controls,=20Settings=20scene,?= =?UTF-8?q?=20export,=20launch=20perms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu gains layoutEditing toggle + bar-width slider, "整理并导出到 Obsidian" + "设置…". Settings scene hosts SettingsView (openSettings from MenuBarExtra). AppDelegate.applicationDidFinishLaunching requests permissions on launch (not lazily on menu-open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index eeedf77..70cd82f 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -8,9 +8,17 @@ struct LiveSubtitleApp: App { @State private var overlay = OverlayController() @State private var running = false @State private var status = "" + @State private var exportStatus = "" + + // 启动即请求权限(麦克风 + 屏幕录制)——用 AppDelegate 的 applicationDidFinishLaunching, + // 而非菜单内容的 .task(后者要等用户点开菜单才触发)。 + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + @Environment(\.openSettings) private var openSettings var body: some Scene { MenuBarExtra("LiveSubtitle", systemImage: "captions.bubble") { + Group { @Bindable var s = store Button(running ? "停止字幕" : "开始字幕") { toggle() } if !status.isEmpty { Text(status).font(.caption) } @@ -34,7 +42,30 @@ struct LiveSubtitleApp: App { Slider(value: $s.fontSize, in: 16...32, step: 1) Divider() + Toggle("布局编辑(拖动字幕条)", isOn: $s.layoutEditing) + Text("字幕条宽度") + Slider(value: $s.barWidth, in: 600...1400, step: 20) + Divider() + + Button("整理并导出到 Obsidian") { exportToObsidian() } + if !exportStatus.isEmpty { Text(exportStatus).font(.caption) } + Button("设置…") { openSettings() } + Divider() + Button("退出") { NSApplication.shared.terminate(nil) } + } + } + + Settings { + SettingsView(store: store) + } + } + + @MainActor private func exportToObsidian() { + exportStatus = "正在整理…" + Task { + let result = await ExportCoordinator.exportToObsidian(store: store) + exportStatus = result } } @@ -50,3 +81,10 @@ struct LiveSubtitleApp: App { } } } + +/// 启动即请求 麦克风 + 屏幕录制 授权(不等到点"开始字幕",也不等用户点开菜单)。 +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + PermissionsManager.requestAllOnLaunch() + } +} From 1468b576bc61796de63354d0a0c804b1e28b7378 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Mon, 13 Jul 2026 23:40:09 +1200 Subject: [PATCH 10/23] feat(phase4): resizable mini window with persisted size Mini panel gains .resizable + minSize; content autoresizes to fill. Size saved to ls.miniW/miniH on didResize and restored on create, alongside the existing ls.miniX/Y position persistence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../LiveSubtitle/Overlay/MiniWindowView.swift | 2 +- .../Overlay/OverlayController.swift | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/LiveSubtitle/Overlay/MiniWindowView.swift b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift index d2c0705..19662dc 100644 --- a/Sources/LiveSubtitle/Overlay/MiniWindowView.swift +++ b/Sources/LiveSubtitle/Overlay/MiniWindowView.swift @@ -19,7 +19,7 @@ struct MiniWindowView: View { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo("bottom", anchor: .bottom) } } } - .frame(width: 380, height: 480) + .frame(minWidth: 260, maxWidth: .infinity, minHeight: 180, maxHeight: .infinity) .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 14)) .overlay(RoundedRectangle(cornerRadius: 14).stroke(.white.opacity(0.12))) } diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 1053991..48d2e62 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -6,6 +6,7 @@ final class OverlayController { private var panel: NSPanel? private var store: SubtitleStore? private var moveObserver: NSObjectProtocol? + private var resizeObserver: NSObjectProtocol? private let defaults = UserDefaults.standard func show(store: SubtitleStore) { @@ -80,15 +81,19 @@ final class OverlayController { } private func makeMiniPanel(_ store: SubtitleStore) -> NSPanel { + let w = defaults.object(forKey: "ls.miniW") as? Double ?? 380 + let h = defaults.object(forKey: "ls.miniH") as? Double ?? 480 let host = NSHostingView(rootView: MiniWindowView(store: store)) - host.frame = NSRect(x: 0, y: 0, width: 380, height: 480) - let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], + host.frame = NSRect(x: 0, y: 0, width: w, height: h) + host.autoresizingMask = [.width, .height] // 内容随 panel 缩放 + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless, .resizable], backing: .buffered, defer: false) p.isFloatingPanel = true p.backgroundColor = .clear p.isOpaque = false p.hasShadow = true - p.isMovableByWindowBackground = true // 小窗可拖 + p.isMovableByWindowBackground = true // 小窗可拖(边缘仍可缩放) + p.minSize = NSSize(width: 260, height: 180) // 缩放下限 p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host if let x = defaults.object(forKey: "ls.miniX") as? Double, @@ -106,10 +111,19 @@ final class OverlayController { self.defaults.set(Double(origin.y), forKey: "ls.miniY") } } + resizeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResizeNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let size = self.panel?.frame.size else { return } + self.defaults.set(Double(size.width), forKey: "ls.miniW") + self.defaults.set(Double(size.height), forKey: "ls.miniH") + } + } return p } private func removeMoveObserver() { if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } + if let o = resizeObserver { NotificationCenter.default.removeObserver(o); resizeObserver = nil } } } From e5137dcf76e842e5921e9cfc3df2b0438ab3b7e8 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Mon, 13 Jul 2026 23:41:36 +1200 Subject: [PATCH 11/23] docs: record Phase 4/5 implementation + update backlog status Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/superpowers/backlog.md | 27 +++++++++---- .../2026-07-13-livesubtitle-phase4-5-impl.md | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md diff --git a/docs/superpowers/backlog.md b/docs/superpowers/backlog.md index e480fe6..b5bff36 100644 --- a/docs/superpowers/backlog.md +++ b/docs/superpowers/backlog.md @@ -2,13 +2,24 @@ > 记录已确认、但排在当前 phase 之后的需求。轮到时各自走 brainstorm→spec→plan。 -## Phase 4 — 字幕条可操作 + 权限前置 +## Phase 4 — 字幕条可操作 + 权限前置 ✅ 代码已实现(2026-07-13,branch phase4-5-perms-bar-obsidian) -- **字幕条(bar)可调**:拖动换位置、缩放大小、调透明度(此前 opacity/字号已在 Phase 3 做;Phase 4 补 bar 的**拖拽 + 缩放**,以及小窗缩放)。 -- **开 app 即请求权限**:启动时就请求 屏幕录制 + 系统音频录制授权(不等到点"开始字幕")。 -- (原计划遗留)回声/外放漏音兜底、音频路由变化处理、延迟调优 spike。 +- ✅ **字幕条(bar)可调**:菜单"布局编辑"开关 → bar 变可交互 + 可拖(关闭恢复点击穿透);位置存 `ls.barX/Y`;宽度用菜单滑条(`store.barWidth`,600–1400)。 +- ✅ **小窗缩放**:mini panel 原生 `.resizable`,内容自适应,尺寸存 `ls.miniW/miniH`。 +- ✅ **开 app 即请求权限**:`AppDelegate.applicationDidFinishLaunching` → `PermissionsManager.requestAllOnLaunch()`(麦克风 `AVCaptureDevice.requestAccess` + 屏幕录制 `CGRequestScreenCaptureAccess`)。**不是**挂菜单 `.task`(那要等点开菜单才触发)。 +- ⏳ **待真机验证**:bar 拖动/宽度、mini 边缘缩放、启动权限框弹出时机(borderless panel 边缘缩放的手感尤其需要肉眼确认)。 +- (原计划遗留,仍 deferred)回声/外放漏音兜底(**已决策:关 VoiceProcessing + 耳机**,见 probes/RESULTS.md)、音频路由变化处理、延迟调优 spike。 -## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 +## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 ✅ 代码已实现(2026-07-13) + +**实现落点(2026-07-13):** + +- ✅ **Obsidian 导出** `Export/ObsidianExporter.swift`:写 `<yyyy-MM-dd>-<title>.md`(frontmatter title/date/tags/source + `## 总结` + `## 转录`),vault 目录不存在则 throw。转录只收 `isFinal` 行,格式 `- **我/对方**:原文 — 译文`。 +- ✅ **DeepSeek** `Export/DeepSeekClient.swift`:POST `api.deepseek.com/chat/completions`,model `deepseek-chat`,`response_format=json_object`,解析 `{title,summary}`。 +- ✅ **编排** `Export/ExportCoordinator.swift`:取终句 → 有 key 调 DeepSeek(失败/无 key 回退默认标题 + 占位 summary,**仍导出**)→ `Task.detached` 写盘。菜单按钮"整理并导出到 Obsidian"触发,状态显示在菜单。**手动触发**(尊重"主动导出才上云"的隐私边界)。 +- ✅ **设置页** `Overlay/SettingsView.swift` + App `Settings` scene(菜单"设置…"用 `openSettings()` 打开):`SecureField` 配 DeepSeek key、`NSOpenPanel` 选 vault 目录。 +- ⚠️ **API key 存 UserDefaults 明文**(自用可接受;未来上架/沙盒 → Keychain)。 +- ⏳ **待真机验证**:设置页打开、选目录、真实 DeepSeek 调用 + 写出 .md(需你填真 key + 选 vault)。 **已定决策(2026-07-09 与用户确认):** @@ -18,6 +29,8 @@ - **设置页**:至少含 DeepSeek API key、Obsidian vault 路径。 - 笔记内容形态待细化:frontmatter(title/date/tags/来源)+ 转录(对方/我)+ DeepSeek 总结段。导出触发时机(停止字幕后?手动按钮?)待 brainstorm。 -## 当前进行 +## 当前进行 / 状态(2026-07-13) -- **Phase 3**:overlay 打磨(菜单栏控制:显示三态 / 字幕条⇄小窗 / Pin / 透明度 / 字号 / 持久化)——spec 已定,进 writing-plans。 +- **Phase 3**(branch `phase3-overlay`,5 commits,18→19 测试绿):代码完成;**Task 6 真机手测仍未做**(你的活)。未合并 main。 +- **Phase 4 + 5**(branch `phase4-5-perms-bar-obsidian`,基于 phase3-overlay,6 commits,19 测试绿,bundle OK):代码完成、编译测试通过;**真机手测未做**。未合并 main。 +- **待你决定**:①Phase 3/4/5 真机验收顺序;②验收 OK 后如何合 main(整条 stack 合,还是逐 phase)。 diff --git a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md new file mode 100644 index 0000000..9c43fcc --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md @@ -0,0 +1,39 @@ +# LiveSubtitle Phase 4 + 5 — 实现记录(2026-07-13) + +> 决策已在 `backlog.md` 拍板(2026-07-09),本次直接实现,未再走完整 brainstorm/spec。 +> branch `phase4-5-perms-bar-obsidian`(基于未合并的 `phase3-overlay`)。多 subagent workflow 产出,主会话逐文件复核 + 亲自 build/test 把关。 + +## 交付(6 commits,19 XCTest 绿,`swift build` 0 error,`build-app.sh` 出 bundle) + +| # | commit | 内容 | +|---|--------|------| +| 1 | `1e6f1e9` | 设置模型:`barWidth`/`deepSeekAPIKey`/`obsidianVaultPath`(持久化)+ `layoutEditing`(瞬态)+ 4 测试 | +| 2 | `b0b84af` | Phase 4:`PermissionsManager`(启动请求麦克风+屏录)、bar 拖动/宽度、bar 位置存 `ls.barX/Y` | +| 3 | `e9b7069` | Phase 5:`ObsidianExporter` / `DeepSeekClient` / `ExportCoordinator` / `SettingsView` | +| 4 | `7714802` | App 接线:菜单控件、`Settings` scene、导出按钮、`AppDelegate` 启动权限 | +| 5 | `1468b57` | Phase 4:mini 窗原生可缩放 + 尺寸持久化 `ls.miniW/miniH` | + +## 关键实现决策 + +- **启动请求权限**用 `@NSApplicationDelegateAdaptor` + `applicationDidFinishLaunching`,**不用**菜单 `.task`(默认 `.menu` 样式的 MenuBarExtra 内容要等用户点开菜单才构建,达不到"开 app 即请求")。 +- **bar 点击穿透是默认**,只有 `layoutEditing==true` 时才 `ignoresMouseEvents=false` + 可拖 + 黄色虚线边框提示;关掉恢复穿透。 +- **bar 尺寸用菜单滑条**(确定性、可测),**mini 用原生边缘缩放**(`.resizable` borderless panel + `host.autoresizingMask` + `.frame(maxWidth/Height:.infinity)`)。 +- **导出手动触发**(菜单按钮),尊重"只有主动导出才把转录发 DeepSeek 云"的隐私边界;无 key/失败都回退并仍写本地 .md。 +- **API key 存 UserDefaults 明文**——自用取舍,未来沙盒化换 Keychain。 + +## Swift 6 并发 + +- 所有新 UI/AppKit 代码 `@MainActor`;新回调无 `@unchecked Sendable`/`nonisolated(unsafe)`。 +- NSWindow move/resize 回调照抄既有 mini 的 `MainActor.assumeIsolated + [weak self]`。 +- `DeepSeekClient` 纯值 struct + async,`Result` Sendable。 + +## ⏳ 未验证(需真机手测,不能靠单测覆盖) + +1. bar 布局编辑拖动 + 宽度滑条实时变化;bar 位置重启后恢复。 +2. mini 边缘缩放手感(borderless panel 边缘抓取区较窄,尤其需肉眼确认)+ 尺寸重启恢复。 +3. 启动时麦克风 + 屏幕录制权限框弹出时机。 +4. 设置页打开、`NSOpenPanel` 选 vault、填真 DeepSeek key → 真实调用 + 写出 .md 到 vault。 + +## 未动 + +音频 / 识别 / 翻译 / 管线(`Audio/**` `Speech/**` `Translation/**` `Pipeline/**`)一行没碰。 From 359b4991460956be5fd3e142532a196114f87e5e Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Mon, 13 Jul 2026 23:54:13 +1200 Subject: [PATCH 12/23] fix: address code-review findings (ghost overlay, export overwrite, re-entrancy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the 5 Phase 4/5 commits surfaced these; verified + fixed the real ones: - [HIGH] OverlayController: hide() left withObservationTracking armed with no visibility guard, so changing overlayMode/pinned/barWidth/layoutEditing after 停止字幕 resurrected a ghost bar with stale lines. Add active flag + generation token; observe()/applyMode() no-op when inactive; hide() invalidates armed callbacks. - [MEDIUM] ObsidianExporter: same-name .md was silently overwritten (data loss when two exports share a title within a minute). Add -2/-3 uniqueness suffix. - [LOW] Export re-entrancy: guard exportToObsidian with isExporting + disable button (avoid duplicate cloud upload + racing writes). - [LOW] frontmatter title: always double-quote + escape \\n/\\r/\\t so a DeepSeek title starting with a YAML indicator (- > | @ etc.) can't break frontmatter. - [LOW] 设置…: NSApp.activate before openSettings so the window fronts from the menu bar. - [LOW] Strengthen transient test to assert ls.layoutEditing is never written (the old assertion was tautological). Not changed (deliberate): API key in UserDefaults (documented tradeoff, Keychain later); barWidth full-rebuild flicker and mini edge-resize grabbability need runtime judgment — left for hands-on manual test. Reverted a speculative "read window from Notification" tweak that broke Swift 6 sending isolation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Export/ObsidianExporter.swift | 26 ++++++++++++++----- Sources/LiveSubtitle/LiveSubtitleApp.swift | 10 ++++++- .../Overlay/OverlayController.swift | 15 +++++++++-- .../SubtitleSettingsTests.swift | 5 ++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/Sources/LiveSubtitle/Export/ObsidianExporter.swift b/Sources/LiveSubtitle/Export/ObsidianExporter.swift index d2918a0..5346494 100644 --- a/Sources/LiveSubtitle/Export/ObsidianExporter.swift +++ b/Sources/LiveSubtitle/Export/ObsidianExporter.swift @@ -56,14 +56,26 @@ enum ObsidianExporter { throw ExportError.vaultNotFound(trimmedVault) } - let fileName = "\(fileDateString(note.date))-\(sanitize(note.title)).md" - let fileURL = vaultURL.appendingPathComponent(fileName, isDirectory: false) + let base = "\(fileDateString(note.date))-\(sanitize(note.title))" + let fileURL = uniqueFileURL(in: vaultURL, base: base) let content = fileContent(for: note) try content.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } + /// 避免覆盖同名笔记:<base>.md 已存在则退到 <base>-2.md / -3.md … + private static func uniqueFileURL(in dir: URL, base: String) -> URL { + let fm = FileManager.default + var candidate = dir.appendingPathComponent("\(base).md", isDirectory: false) + var n = 2 + while fm.fileExists(atPath: candidate.path) { + candidate = dir.appendingPathComponent("\(base)-\(n).md", isDirectory: false) + n += 1 + } + return candidate + } + // MARK: - Helpers private static func displayName(for speaker: Speaker) -> String { @@ -120,14 +132,16 @@ enum ObsidianExporter { return out } - /// 对 YAML scalar 做最小转义:含特殊字符时用双引号包裹并转义内部引号/反斜杠。 + /// title 一律用双引号包裹并完整转义(反斜杠/引号/换行/回车/制表符)。 + /// 双引号 scalar 不会被 YAML 当成列表(- )、块(| >)、锚点(& *)、指示符(@ ` ! ?)等解析, + /// 也不会因内嵌换行断开 frontmatter,因此对 DeepSeek 返回的任意 title 都安全。 private static func yamlScalar(_ value: String) -> String { - let needsQuoting = value.contains(where: { ":#\"'[]{}".contains($0) }) - || value.hasPrefix(" ") || value.hasSuffix(" ") - guard needsQuoting else { return value } let escaped = value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") return "\"\(escaped)\"" } } diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 70cd82f..2276d79 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -9,6 +9,7 @@ struct LiveSubtitleApp: App { @State private var running = false @State private var status = "" @State private var exportStatus = "" + @State private var isExporting = false // 启动即请求权限(麦克风 + 屏幕录制)——用 AppDelegate 的 applicationDidFinishLaunching, // 而非菜单内容的 .task(后者要等用户点开菜单才触发)。 @@ -48,8 +49,12 @@ struct LiveSubtitleApp: App { Divider() Button("整理并导出到 Obsidian") { exportToObsidian() } + .disabled(isExporting) if !exportStatus.isEmpty { Text(exportStatus).font(.caption) } - Button("设置…") { openSettings() } + Button("设置…") { + NSApp.activate(ignoringOtherApps: true) // 菜单栏触发时确保设置窗口置前获焦 + openSettings() + } Divider() Button("退出") { NSApplication.shared.terminate(nil) } @@ -62,10 +67,13 @@ struct LiveSubtitleApp: App { } @MainActor private func exportToObsidian() { + guard !isExporting else { return } // 防重入:进行中不再并发触发(避免重复上云 + 竞争写盘) + isExporting = true exportStatus = "正在整理…" Task { let result = await ExportCoordinator.exportToObsidian(store: store) exportStatus = result + isExporting = false } } diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 48d2e62..33ac6ca 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -8,22 +8,32 @@ final class OverlayController { private var moveObserver: NSObjectProtocol? private var resizeObserver: NSObjectProtocol? private let defaults = UserDefaults.standard + /// overlay 是否处于显示态。停止后为 false,阻止观察回调复活幽灵浮窗。 + private var active = false + /// 观察代次:show/hide 时自增,使 hide 前武装的旧 withObservationTracking 回调失效(无法 cancel,只能靠代次作废)。 + private var generation = 0 func show(store: SubtitleStore) { self.store = store + active = true + generation &+= 1 applyMode() observe() } func hide() { + active = false + generation &+= 1 // 作废任何已武装的观察回调 removeMoveObserver() panel?.orderOut(nil) panel = nil } /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 + /// active==false(已停止)或代次过期时,回调直接 no-op,不再重建浮窗、不再重新武装。 private func observe() { - guard let store else { return } + guard active, let store else { return } + let gen = generation withObservationTracking { _ = store.overlayMode _ = store.pinned @@ -31,6 +41,7 @@ final class OverlayController { _ = store.layoutEditing } onChange: { Task { @MainActor in + guard self.active, gen == self.generation else { return } self.applyMode() self.observe() } @@ -38,7 +49,7 @@ final class OverlayController { } private func applyMode() { - guard let store else { return } + guard active, let store else { return } removeMoveObserver() panel?.orderOut(nil) let p = (store.overlayMode == .bar) ? makeBarPanel(store) : makeMiniPanel(store) diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift index 78d3b19..0e613c1 100644 --- a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -46,6 +46,11 @@ final class SubtitleSettingsTests: XCTestCase { let suite = freshSuite() let s1 = SubtitleStore(defaults: suite) s1.layoutEditing = true + // 核心断言:瞬态属性【绝不落盘】——直接查底层 key 未被写入。 + // (仅断言 s2.layoutEditing==false 是恒真的:init 无条件置 false, + // 即使误加了持久化 didSet 也照样通过,抓不到回归。) + XCTAssertNil(suite.object(forKey: "ls.layoutEditing"), + "layoutEditing 不应写入 UserDefaults(瞬态语义被破坏)") let s2 = SubtitleStore(defaults: suite) XCTAssertFalse(s2.layoutEditing) } From cf247ce4d4876be14fe348d0bc17ae2ac20930ed Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 12:06:18 +1200 Subject: [PATCH 13/23] feat(translation): live volatile translation + failure fallback + skip in original-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three translation gaps from the review closed: - T1 边说边译: translate the volatile (in-progress) line too, not just finals, so Chinese appears ~mid-sentence instead of a full sentence late. Throttled loop (~450ms) gated by a persisted translateVolatile toggle (menu "边说边译(中间态)", default on) + displayMode; per-speaker dedup + in-flight guard; store-side attachVolatileTranslation only applies when the volatile line still exists, is still non-final, and its text is unchanged (drops stale fragments). - T2 失败回退: translate() nil now marks the line translationFailed; UI shows the original as fallback in translated-only mode (no more blank line) and "(译文不可用)" in bilingual mode, instead of a permanent "翻译中…". - T3 原文模式不翻译: skip translate() when displayMode is original-only (PRD intent). TranslationService.translate is now serialized (task chain) so concurrent final + volatile calls never hit one TranslationSession at once. +7 store tests (26 total). Note: T1 volatile translation quality/flicker on partial fragments needs real-audio judgment — one toggle away from off if it reads worse than "one sentence behind". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 1 + .../LiveSubtitle/Models/SubtitleModels.swift | 5 +- .../LiveSubtitle/Models/SubtitleStore.swift | 23 ++++++++ .../Overlay/SubtitleBarView.swift | 9 +++ .../LiveSubtitle/Pipeline/CaptionEngine.swift | 36 ++++++++++- .../Translation/TranslationService.swift | 15 ++++- .../SubtitleSettingsTests.swift | 3 + .../SubtitleStoreTests.swift | 59 +++++++++++++++++++ 8 files changed, 144 insertions(+), 7 deletions(-) diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 2276d79..8ec6d48 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -30,6 +30,7 @@ struct LiveSubtitleApp: App { Text("双语").tag(DisplayMode.both) Text("译文").tag(DisplayMode.translatedOnly) } + Toggle("边说边译(中间态)", isOn: $s.translateVolatile) Picker("形态", selection: $s.overlayMode) { Text("字幕条").tag(OverlayMode.bar) Text("小窗").tag(OverlayMode.mini) diff --git a/Sources/LiveSubtitle/Models/SubtitleModels.swift b/Sources/LiveSubtitle/Models/SubtitleModels.swift index 596ce1d..22103f3 100644 --- a/Sources/LiveSubtitle/Models/SubtitleModels.swift +++ b/Sources/LiveSubtitle/Models/SubtitleModels.swift @@ -23,10 +23,13 @@ struct SubtitleLine: Identifiable, Sendable { var original: String var translated: String? var isFinal: Bool + /// 翻译已尝试但失败(区别于「尚未翻译/翻译中」)。UI 据此回退显原文而非永久「翻译中…」。 + var translationFailed: Bool init(id: UUID = UUID(), speaker: Speaker, original: String, - translated: String? = nil, isFinal: Bool = false) { + translated: String? = nil, isFinal: Bool = false, translationFailed: Bool = false) { self.id = id; self.speaker = speaker; self.original = original self.translated = translated; self.isFinal = isFinal + self.translationFailed = translationFailed } } diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index 46dc0d2..4bc710c 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -15,6 +15,8 @@ final class SubtitleStore { var barWidth: Double { didSet { defaults.set(barWidth, forKey: "ls.barWidth") } } var deepSeekAPIKey: String { didSet { defaults.set(deepSeekAPIKey, forKey: "ls.deepSeekKey") } } var obsidianVaultPath: String { didSet { defaults.set(obsidianVaultPath, forKey: "ls.vaultPath") } } + /// 边说边译:对未定稿的中间态也翻译(降延迟,代价是译文会随句子生长而跳变)。 + var translateVolatile: Bool { didSet { defaults.set(translateVolatile, forKey: "ls.translateVolatile") } } /// 布局编辑态,瞬态(不持久化),启动永远 false。 var layoutEditing: Bool = false @@ -35,6 +37,7 @@ final class SubtitleStore { barWidth = defaults.object(forKey: "ls.barWidth") as? Double ?? 900 deepSeekAPIKey = defaults.string(forKey: "ls.deepSeekKey") ?? "" obsidianVaultPath = defaults.string(forKey: "ls.vaultPath") ?? "" + translateVolatile = defaults.object(forKey: "ls.translateVolatile") as? Bool ?? true layoutEditing = false } @@ -79,5 +82,25 @@ final class SubtitleStore { func attachTranslation(id: UUID, zh: String) { guard let i = lines.firstIndex(where: { $0.id == id }) else { return } lines[i].translated = zh + lines[i].translationFailed = false // 成功则清除任何旧的失败标记 + } + + /// 翻译尝试失败:打标记,UI 据此回退显原文而非永久「翻译中…」。 + func markTranslationFailed(id: UUID) { + guard let i = lines.firstIndex(where: { $0.id == id }) else { return } + if lines[i].translated == nil { lines[i].translationFailed = true } + } + + /// 当前某 speaker 未定稿中间态的原文(供边说边译读取);无则 nil。 + func currentVolatileText(speaker: Speaker) -> String? { + guard let i = volatileIndex[speaker] else { return nil } + return lines[i].original + } + + /// 回填中间态译文:仅当该 speaker 的中间态行仍存在、仍未定稿、且原文未变(== sourceText)时才应用, + /// 避免把过期片段的译文贴到已被新内容替换或已定稿的行上。 + func attachVolatileTranslation(speaker: Speaker, sourceText: String, zh: String) { + guard let i = volatileIndex[speaker], !lines[i].isFinal, lines[i].original == sourceText else { return } + lines[i].translated = zh } } diff --git a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift index 782c9e3..025f511 100644 --- a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift +++ b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift @@ -22,6 +22,15 @@ struct SubtitleLineRow: View { if displayMode.showsTranslated { if let zh = line.translated { Text(zh).font(.system(size: fontSize, weight: .medium)).foregroundStyle(.white) + } else if line.translationFailed { + if displayMode == .translatedOnly { + // 仅译文模式:译文不可用时回退显原文,避免整行空白 + Text(line.original).font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(0.85)) + } else { + Text("(译文不可用)").font(.system(size: fontSize * 0.6)) + .foregroundStyle(.white.opacity(0.35)) + } } else if line.isFinal { Text("翻译中…").font(.system(size: fontSize * 0.64)).foregroundStyle(.white.opacity(0.3)) } diff --git a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift index 14e4bfe..5a64df0 100644 --- a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift +++ b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift @@ -7,6 +7,9 @@ final class CaptionEngine { private var tracks: [Track] private var tasks: [Task<Void, Never>] = [] private var flushTask: Task<Void, Never>? + private var volatileTranslateTask: Task<Void, Never>? + private var lastVolatileSource: [Speaker: String] = [:] // 每 speaker 上次已送翻译的中间态,去重 + private var volatileInFlight: Set<Speaker> = [] // 每 speaker 是否有中间态翻译在途,防叠 /// 默认双轨:对方(系统音)+ 我(麦克风)。测试可注入自定义轨。 init(store: SubtitleStore, tracks: [(AudioSource, TranscriptionPipeline)]? = nil) { @@ -37,9 +40,13 @@ final class CaptionEngine { for await e in events { if e.isFinal { let id = store.commitFinal(speaker: track.source.speaker, text: e.text) - Task { @MainActor in - if let zh = await track.translator.translate(e.text) { - store.attachTranslation(id: id, zh: zh) + if store.displayMode.showsTranslated { // 原文模式不触发翻译(省资源,对齐 PRD) + Task { @MainActor in + if let zh = await track.translator.translate(e.text) { + store.attachTranslation(id: id, zh: zh) + } else { + store.markTranslationFailed(id: id) // 失败打标,UI 回退显原文 + } } } } else { @@ -63,10 +70,33 @@ final class CaptionEngine { store.flushVolatile() } } + // 边说边译:每 ~450ms 把当前中间态送翻译(受 translateVolatile + displayMode 门控; + // 每 speaker 内容未变则跳过、在途则不叠;译文经 attachVolatileTranslation 守卫回填)。 + volatileTranslateTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(450)) + guard store.translateVolatile, store.displayMode.showsTranslated else { continue } + for track in tracks { + let sp = track.source.speaker + guard !volatileInFlight.contains(sp), + let text = store.currentVolatileText(speaker: sp), + !text.isEmpty, text != lastVolatileSource[sp] else { continue } + lastVolatileSource[sp] = text + volatileInFlight.insert(sp) + let translator = track.translator + Task { @MainActor in + let zh = await translator.translate(text) + volatileInFlight.remove(sp) + if let zh { store.attachVolatileTranslation(speaker: sp, sourceText: text, zh: zh) } + } + } + } + } } func stop() { flushTask?.cancel(); flushTask = nil + volatileTranslateTask?.cancel(); volatileTranslateTask = nil tasks.forEach { $0.cancel() }; tasks = [] let tracks = self.tracks // 先并发停所有采集源(麦克风立即停录),再并发收尾所有 pipeline diff --git a/Sources/LiveSubtitle/Translation/TranslationService.swift b/Sources/LiveSubtitle/Translation/TranslationService.swift index 2f90bb0..662d254 100644 --- a/Sources/LiveSubtitle/Translation/TranslationService.swift +++ b/Sources/LiveSubtitle/Translation/TranslationService.swift @@ -4,6 +4,9 @@ import Foundation @MainActor final class TranslationService { private var session: TranslationSession? + /// 串行化:final 与 volatile 可能并发调 translate,单个 TranslationSession 不保证并发安全, + /// 用任务链把调用排队,保证同一 session 同一时刻只有一个 translate 在跑。 + private var queue: Task<String?, Never>? enum TranslateError: Error { case notInstalled @@ -22,9 +25,15 @@ final class TranslationService { session = s } - /// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。 + /// 单句英→中。返回中文;失败返回 nil(调用方回退显示原文)。调用被串行排队。 func translate(_ en: String) async -> String? { - guard let s = session else { return nil } - return try? await s.translate(en).targetText + let previous = queue + let task = Task { @MainActor () -> String? in + _ = await previous?.value // 等前一个 translate 完成,避免并发访问 session + guard let s = session else { return nil } + return try? await s.translate(en).targetText + } + queue = task + return await task.value } } diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift index 0e613c1..3871dea 100644 --- a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -18,6 +18,7 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s.deepSeekAPIKey, "") XCTAssertEqual(s.obsidianVaultPath, "") XCTAssertFalse(s.layoutEditing) + XCTAssertTrue(s.translateVolatile) // 默认开(边说边译) } func testSettingsPersistAcrossInstances() { @@ -31,6 +32,7 @@ final class SubtitleSettingsTests: XCTestCase { s1.barWidth = 1200 s1.deepSeekAPIKey = "sk-test-123" s1.obsidianVaultPath = "/Users/me/Vault" + s1.translateVolatile = false let s2 = SubtitleStore(defaults: suite) XCTAssertEqual(s2.displayMode, .translatedOnly) XCTAssertEqual(s2.overlayMode, .mini) @@ -40,6 +42,7 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s2.barWidth, 1200, accuracy: 0.0001) XCTAssertEqual(s2.deepSeekAPIKey, "sk-test-123") XCTAssertEqual(s2.obsidianVaultPath, "/Users/me/Vault") + XCTAssertFalse(s2.translateVolatile) } func testLayoutEditingIsTransient() { diff --git a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift index 9f80efc..7c7e950 100644 --- a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift @@ -75,4 +75,63 @@ final class SubtitleStoreTests: XCTestCase { XCTAssertEqual(s.lines[0].original, "final text") XCTAssertTrue(s.lines[0].isFinal) } + + // MARK: - T2 翻译失败回退 + + func testMarkTranslationFailedSetsFlagWhenUntranslated() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.markTranslationFailed(id: id) + XCTAssertTrue(s.lines[0].translationFailed) + XCTAssertNil(s.lines[0].translated) + } + + func testMarkTranslationFailedNoOpWhenAlreadyTranslated() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.attachTranslation(id: id, zh: "你好。") + s.markTranslationFailed(id: id) // 已有译文,不应打失败标 + XCTAssertFalse(s.lines[0].translationFailed) + XCTAssertEqual(s.lines[0].translated, "你好。") + } + + func testAttachTranslationClearsFailedFlag() { + let s = SubtitleStore() + let id = s.commitFinal(speaker: .other, text: "Hello.") + s.markTranslationFailed(id: id) + s.attachTranslation(id: id, zh: "你好。") // 成功回填应清除失败标 + XCTAssertFalse(s.lines[0].translationFailed) + XCTAssertEqual(s.lines[0].translated, "你好。") + } + + // MARK: - T1 边说边译(中间态翻译)守卫 + + func testCurrentVolatileTextReflectsLatestVolatile() { + let s = SubtitleStore() + XCTAssertNil(s.currentVolatileText(speaker: .other)) + s.upsertVolatile(speaker: .other, text: "I think we") + XCTAssertEqual(s.currentVolatileText(speaker: .other), "I think we") + } + + func testAttachVolatileTranslationAppliesWhenTextMatches() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertEqual(s.lines[0].translated, "我认为我们") + } + + func testAttachVolatileTranslationDroppedWhenTextChanged() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we should") // 原文已生长 + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") // 过期片段 + XCTAssertNil(s.lines[0].translated) // 不应贴过期译文 + } + + func testAttachVolatileTranslationDroppedAfterFinal() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + _ = s.commitFinal(speaker: .other, text: "I think we should go.") // 已定稿,volatileIndex 清空 + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertNil(s.lines[0].translated) // 不应回填到已定稿行 + } } From 0dfd750a9a210fe8a35554200897a896c421dbd4 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 13:50:00 +1200 Subject: [PATCH 14/23] fix: address full-codebase review findings (orphan SCStream, stale translation, silent STT freeze) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full 6-agent review; verified each and fixed the real ones: - [HIGH] SystemAudioSource start/stop race orphaned a live SCStream (screen recording stayed on after 停止, CPU churn, accumulates on quick restart). start() is now stored + a `stopped` flag makes a late-resolving start() tear down its own stream; stop() awaits the start task then stops any built stream. - [MEDIUM] commitFinal carried a volatile half-sentence translation onto the final line; if the final re-translation failed it showed permanently as the full translation. Added translationProvisional: markTranslationFailed now clears a provisional translation and falls back to original; attachTranslation clears it on success. - [MEDIUM] Old per-track consume task kept writing finals into the shared store after stop()/engine=nil, corrupting a fast restart. Added a `stopped` guard that breaks the loop. - [MEDIUM] transcriber.results errors were swallowed (silent caption freeze). Pipeline.start now takes onError and surfaces "识别中断…" to the UI. - [LOW] Stopping during STT startup no longer shows a spurious "启动失败: cancelled" (ignore CancellationError). - [LOW] lastVolatileSource cleared on commitFinal so a repeated short phrase in the next sentence still gets live translation. - [LOW] Restored overlay coords are validated against current screens; off-screen → default. - [LOW] DeepSeek request timeout 30s (was default 60s silent hang); export failure message now names a short reason (missing key / HTTP code / network) instead of one generic line. Deferred (need runtime judgment / design call, noted for manual-test pass): bar panel fixed 200pt height clips at max font+bilingual+3 lines; mini rebuild on layoutEditing toggle; unbounded lines growth + O(n) id scans over long sessions (cap vs keep-for-export). +3 store tests (29 total). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Audio/SystemAudioSource.swift | 14 ++++++-- .../LiveSubtitle/Export/DeepSeekClient.swift | 1 + .../Export/ExportCoordinator.swift | 14 +++++++- .../LiveSubtitle/Models/SubtitleModels.swift | 7 +++- .../LiveSubtitle/Models/SubtitleStore.swift | 13 ++++++-- .../Overlay/OverlayController.swift | 33 +++++++++++++++---- .../LiveSubtitle/Pipeline/CaptionEngine.swift | 11 ++++++- .../Speech/TranscriptionPipeline.swift | 7 ++-- .../SubtitleStoreTests.swift | 33 +++++++++++++++++++ 9 files changed, 117 insertions(+), 16 deletions(-) diff --git a/Sources/LiveSubtitle/Audio/SystemAudioSource.swift b/Sources/LiveSubtitle/Audio/SystemAudioSource.swift index fe95834..b74b93c 100644 --- a/Sources/LiveSubtitle/Audio/SystemAudioSource.swift +++ b/Sources/LiveSubtitle/Audio/SystemAudioSource.swift @@ -11,17 +11,20 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe private let converter = FormatConverter() private var stream: SCStream? private var continuation: AsyncStream<AudioFrame>.Continuation? + private var startTask: Task<Void, Never>? + private var stopped = false // stop 已调用;start 在异步就绪后据此自拆,避免孤儿 SCStream func frames() -> AsyncStream<AudioFrame> { AsyncStream(bufferingPolicy: .bufferingNewest(32)) { cont in self.continuation = cont - Task { await self.start() } + self.startTask = Task { await self.start() } } } private func start() async { do { let content = try await SCShareableContent.current + if stopped { continuation?.finish(); return } // 解析期间已 stop → 不再建流 guard let display = content.displays.first else { onError?("未找到可采集的显示器"); continuation?.finish(); return } @@ -35,6 +38,10 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe let s = SCStream(filter: filter, configuration: config, delegate: self) try s.addStreamOutput(self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "sysaudio")) try await s.startCapture() + if stopped { // startCapture 期间已 stop → 立即拆掉,别留孤儿流 + try? await s.stopCapture() + continuation?.finish(); return + } stream = s } catch { onError?("系统音频采集失败:\(error.localizedDescription) — 请在 系统设置→隐私与安全性→屏幕录制 授权 LiveSubtitle") @@ -49,7 +56,10 @@ final class SystemAudioSource: NSObject, AudioSource, SCStreamOutput, SCStreamDe } func stop() async { - try? await stream?.stopCapture() + stopped = true + startTask?.cancel() + _ = await startTask?.value // 等在途 start 结束:它要么自拆、要么已把 stream 设好 + try? await stream?.stopCapture() // 兜底停掉已建好的流 stream = nil continuation?.finish() } diff --git a/Sources/LiveSubtitle/Export/DeepSeekClient.swift b/Sources/LiveSubtitle/Export/DeepSeekClient.swift index 8992944..8e6c673 100644 --- a/Sources/LiveSubtitle/Export/DeepSeekClient.swift +++ b/Sources/LiveSubtitle/Export/DeepSeekClient.swift @@ -71,6 +71,7 @@ struct DeepSeekClient { var request = URLRequest(url: Self.endpoint) request.httpMethod = "POST" + request.timeoutInterval = 30 // 避免半开网络下默认 60s 静默挂起 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(body) diff --git a/Sources/LiveSubtitle/Export/ExportCoordinator.swift b/Sources/LiveSubtitle/Export/ExportCoordinator.swift index 9b25252..42c8680 100644 --- a/Sources/LiveSubtitle/Export/ExportCoordinator.swift +++ b/Sources/LiveSubtitle/Export/ExportCoordinator.swift @@ -27,7 +27,7 @@ enum ExportCoordinator { summary = result.summary } catch { title = fallbackTitle(now) - summary = "(DeepSeek 总结失败,已跳过)" + summary = "(DeepSeek 总结失败:\(shortReason(error)),已跳过)" } } else { title = fallbackTitle(now) @@ -52,6 +52,18 @@ enum ExportCoordinator { } /// 无 DeepSeek 时的回退标题:只用时刻(日期已在文件名前缀里,避免重复)。 + /// 失败原因的简短可读描述(区分坏 key / 网络 / 限流等),不塞 http body 长串。 + private static func shortReason(_ error: Error) -> String { + if let e = error as? DeepSeekClient.DeepSeekError { + switch e { + case .missingKey: return "未配置 key" + case .http(let code, _): return "HTTP \(code)" + case .badResponse: return "响应异常" + } + } + return (error as NSError).localizedDescription // 网络类(超时/断网等) + } + private static func fallbackTitle(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/Sources/LiveSubtitle/Models/SubtitleModels.swift b/Sources/LiveSubtitle/Models/SubtitleModels.swift index 22103f3..e7f9848 100644 --- a/Sources/LiveSubtitle/Models/SubtitleModels.swift +++ b/Sources/LiveSubtitle/Models/SubtitleModels.swift @@ -25,11 +25,16 @@ struct SubtitleLine: Identifiable, Sendable { var isFinal: Bool /// 翻译已尝试但失败(区别于「尚未翻译/翻译中」)。UI 据此回退显原文而非永久「翻译中…」。 var translationFailed: Bool + /// 当前 translated 来自中间态的半句译文(临时)。终句成句译文到位前为 true; + /// 若终句重译失败,这份半句译文不可当作定稿译文,应回退显原文。 + var translationProvisional: Bool init(id: UUID = UUID(), speaker: Speaker, original: String, - translated: String? = nil, isFinal: Bool = false, translationFailed: Bool = false) { + translated: String? = nil, isFinal: Bool = false, + translationFailed: Bool = false, translationProvisional: Bool = false) { self.id = id; self.speaker = speaker; self.original = original self.translated = translated; self.isFinal = isFinal self.translationFailed = translationFailed + self.translationProvisional = translationProvisional } } diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index 4bc710c..aac9f2a 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -82,13 +82,19 @@ final class SubtitleStore { func attachTranslation(id: UUID, zh: String) { guard let i = lines.firstIndex(where: { $0.id == id }) else { return } lines[i].translated = zh - lines[i].translationFailed = false // 成功则清除任何旧的失败标记 + lines[i].translationFailed = false // 成功则清除任何旧的失败标记 + lines[i].translationProvisional = false // 定稿译文,不再是临时半句 } - /// 翻译尝试失败:打标记,UI 据此回退显原文而非永久「翻译中…」。 + /// 翻译尝试失败:打标记,UI 据此回退显原文而非永久「翻译中…」或残留的半句译文。 + /// 未翻译(nil)或仅有中间态临时译文(provisional)时都视为失败并清掉临时译文。 func markTranslationFailed(id: UUID) { guard let i = lines.firstIndex(where: { $0.id == id }) else { return } - if lines[i].translated == nil { lines[i].translationFailed = true } + if lines[i].translated == nil || lines[i].translationProvisional { + lines[i].translated = nil + lines[i].translationProvisional = false + lines[i].translationFailed = true + } } /// 当前某 speaker 未定稿中间态的原文(供边说边译读取);无则 nil。 @@ -102,5 +108,6 @@ final class SubtitleStore { func attachVolatileTranslation(speaker: Speaker, sourceText: String, zh: String) { guard let i = volatileIndex[speaker], !lines[i].isFinal, lines[i].original == sourceText else { return } lines[i].translated = zh + lines[i].translationProvisional = true // 半句临时译文;终句译文到位或失败时会被覆盖/清除 } } diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 33ac6ca..4c9e2c4 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -73,12 +73,19 @@ final class OverlayController { p.isMovableByWindowBackground = store.layoutEditing p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host + let barDefault: NSPoint = { + if let screen = NSScreen.main { + let f = screen.visibleFrame + return NSPoint(x: f.midX - width / 2, y: f.minY + 60) + } + return NSPoint(x: 100, y: 100) + }() if let x = defaults.object(forKey: "ls.barX") as? Double, let y = defaults.object(forKey: "ls.barY") as? Double { p.setFrameOrigin(NSPoint(x: x, y: y)) - } else if let screen = NSScreen.main { - let f = screen.visibleFrame - p.setFrameOrigin(NSPoint(x: f.midX - width / 2, y: f.minY + 60)) + ensureVisible(p, fallback: barDefault) + } else { + p.setFrameOrigin(barDefault) } moveObserver = NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in @@ -107,12 +114,19 @@ final class OverlayController { p.minSize = NSSize(width: 260, height: 180) // 缩放下限 p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.contentView = host + let miniDefault: NSPoint = { + if let screen = NSScreen.main { + let f = screen.visibleFrame + return NSPoint(x: f.maxX - 400, y: f.minY + 80) + } + return NSPoint(x: 100, y: 100) + }() if let x = defaults.object(forKey: "ls.miniX") as? Double, let y = defaults.object(forKey: "ls.miniY") as? Double { p.setFrameOrigin(NSPoint(x: x, y: y)) - } else if let screen = NSScreen.main { - let f = screen.visibleFrame - p.setFrameOrigin(NSPoint(x: f.maxX - 400, y: f.minY + 80)) + ensureVisible(p, fallback: miniDefault) + } else { + p.setFrameOrigin(miniDefault) } moveObserver = NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in @@ -137,4 +151,11 @@ final class OverlayController { if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } if let o = resizeObserver { NotificationCenter.default.removeObserver(o); resizeObserver = nil } } + + /// 恢复的坐标可能因显示器拔插/分辨率变化落到屏外(浮窗不可见且无恢复入口); + /// 不与任一屏可见区相交则回退到默认位置。 + private func ensureVisible(_ p: NSPanel, fallback: NSPoint) { + let onScreen = NSScreen.screens.contains { $0.visibleFrame.intersects(p.frame) } + if !onScreen { p.setFrameOrigin(fallback) } + } } diff --git a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift index 5a64df0..d32f371 100644 --- a/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift +++ b/Sources/LiveSubtitle/Pipeline/CaptionEngine.swift @@ -10,6 +10,7 @@ final class CaptionEngine { private var volatileTranslateTask: Task<Void, Never>? private var lastVolatileSource: [Speaker: String] = [:] // 每 speaker 上次已送翻译的中间态,去重 private var volatileInFlight: Set<Speaker> = [] // 每 speaker 是否有中间态翻译在途,防叠 + private var stopped = false // stop 后为 true,阻止旧 consume 继续写共享 store /// 默认双轨:对方(系统音)+ 我(麦克风)。测试可注入自定义轨。 init(store: SubtitleStore, tracks: [(AudioSource, TranscriptionPipeline)]? = nil) { @@ -35,11 +36,16 @@ final class CaptionEngine { tasks.append(Task { do { try await track.pipeline.ensureModel() - let events = try await track.pipeline.start() + // 识别流中途抛错 → 上报,避免字幕静默冻结 + let events = try await track.pipeline.start(onError: { msg in + Task { @MainActor in onError(msg) } + }) let consume = Task { @MainActor in for await e in events { + if stopped { break } // stop 后不再写共享 store(防旧会话污染快速重启的新会话) if e.isFinal { let id = store.commitFinal(speaker: track.source.speaker, text: e.text) + lastVolatileSource[track.source.speaker] = nil // 定稿后清去重,下句同短语也能边说边译 if store.displayMode.showsTranslated { // 原文模式不触发翻译(省资源,对齐 PRD) Task { @MainActor in if let zh = await track.translator.translate(e.text) { @@ -58,6 +64,8 @@ final class CaptionEngine { await track.pipeline.feed(frame) } await consume.value + } catch is CancellationError { + // 用户主动停止,不当作错误上报 } catch { onError("启动失败:\(error.localizedDescription)") } @@ -95,6 +103,7 @@ final class CaptionEngine { } func stop() { + stopped = true flushTask?.cancel(); flushTask = nil volatileTranslateTask?.cancel(); volatileTranslateTask = nil tasks.forEach { $0.cancel() }; tasks = [] diff --git a/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift b/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift index 913c62f..1b59e8c 100644 --- a/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift +++ b/Sources/LiveSubtitle/Speech/TranscriptionPipeline.swift @@ -29,7 +29,8 @@ actor TranscriptionPipeline { } /// 启动:返回结果事件流;内部起分析 Task。 - func start() async throws -> AsyncStream<TranscriptEvent> { + /// onError:识别流中途抛错时上报(否则字幕会静默冻结,用户无从察觉)。 + func start(onError: (@Sendable (String) -> Void)? = nil) async throws -> AsyncStream<TranscriptEvent> { let (inStream, inCont) = AsyncStream<AnalyzerInput>.makeStream() inputCont = inCont try await analyzer.start(inputSequence: inStream) @@ -39,7 +40,9 @@ actor TranscriptionPipeline { for try await r in transcriber.results { cont.yield(TranscriptEvent(text: String(r.text.characters), isFinal: r.isFinal)) } - } catch { } + } catch { + onError?("识别中断:\(error.localizedDescription) — 请停止后重开字幕") + } cont.finish() } } diff --git a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift index 7c7e950..5eed68b 100644 --- a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift @@ -134,4 +134,37 @@ final class SubtitleStoreTests: XCTestCase { s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") XCTAssertNil(s.lines[0].translated) // 不应回填到已定稿行 } + + // MARK: - 半句临时译文(provisional)在定稿后的处理 + + func testCommitFinalCarriesProvisionalVolatileTranslation() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + XCTAssertTrue(s.lines[0].translationProvisional) + _ = s.commitFinal(speaker: .other, text: "I think we should cancel.") + XCTAssertEqual(s.lines[0].translated, "我认为我们") // 半句译文暂留(平滑过渡) + XCTAssertTrue(s.lines[0].translationProvisional) // 仍是临时,未定稿 + } + + func testMarkFailedClearsProvisionalTranslation() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + let id = s.commitFinal(speaker: .other, text: "I think we should cancel.") + s.markTranslationFailed(id: id) // 终句重译失败 + XCTAssertNil(s.lines[0].translated) // 清掉过期半句译文,别当定稿译文 + XCTAssertTrue(s.lines[0].translationFailed) // 回退显原文 + XCTAssertFalse(s.lines[0].translationProvisional) + } + + func testAttachTranslationClearsProvisional() { + let s = SubtitleStore() + s.upsertVolatile(speaker: .other, text: "I think we") + s.attachVolatileTranslation(speaker: .other, sourceText: "I think we", zh: "我认为我们") + let id = s.commitFinal(speaker: .other, text: "I think we should cancel.") + s.attachTranslation(id: id, zh: "我认为我们应该取消。") // 终句成句译文到位 + XCTAssertEqual(s.lines[0].translated, "我认为我们应该取消。") + XCTAssertFalse(s.lines[0].translationProvisional) + } } From 61c3c17e9af3b33be7fe7c7ea9103958b0f1b850 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 13:58:48 +1200 Subject: [PATCH 15/23] =?UTF-8?q?perf(store):=20O(1)=20id=E2=86=92index=20?= =?UTF-8?q?lookups=20+=20bounded=20line=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachTranslation/markTranslationFailed no longer O(n) firstIndex scans over unbounded history — an indexByID map makes them O(1). volatileIndex is now id-based (not absolute index) so trimming can't invalidate it. Lines are capped at maxLines=2000 (oldest dropped, rebuild index); ample for calls/meetings, export uses retained lines. +2 tests (cap + volatile-survives-trim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../LiveSubtitle/Models/SubtitleStore.swift | 53 ++++++++++++++----- .../SubtitleStoreTests.swift | 21 ++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index aac9f2a..ddd20ea 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -21,12 +21,20 @@ final class SubtitleStore { /// 布局编辑态,瞬态(不持久化),启动永远 false。 var layoutEditing: Bool = false - /// 每个 speaker 的"当前未定稿灰字行"索引;定稿后清除。 - private var volatileIndex: [Speaker: Int] = [:] + /// 每个 speaker 的"当前未定稿灰字行"id;定稿后清除。 + /// 用 id(而非绝对下标),这样截断旧行后仍能正确定位,不会失效或错位。 + private var volatileIndex: [Speaker: UUID] = [:] + + /// id → lines 下标,O(1) 定位;避免每次终句翻译回填做 O(n) firstIndex 扫描。截断时重建。 + private var indexByID: [UUID: Int] = [:] /// 每个 speaker 的暂存中间态,尚未上屏(由节流器 flush)。 private var pendingVolatile: [Speaker: String] = [:] + /// 保留的最大行数上限;超出则从最旧行开始丢弃,避免长会话内存/渲染无界增长。 + /// 取值足够大,正常通话/会议不会触及;导出用当前保留的行(极长会话会丢最早的回看历史)。 + private let maxLines = 2000 + init(defaults: UserDefaults = .standard) { self.defaults = defaults displayMode = DisplayMode(rawValue: defaults.string(forKey: "ls.displayMode") ?? "") ?? .both @@ -55,11 +63,12 @@ final class SubtitleStore { } func upsertVolatile(speaker: Speaker, text: String) { - if let i = volatileIndex[speaker] { + if let id = volatileIndex[speaker], let i = index(of: id) { lines[i].original = text } else { - lines.append(SubtitleLine(speaker: speaker, original: text, isFinal: false)) - volatileIndex[speaker] = lines.count - 1 + let line = SubtitleLine(speaker: speaker, original: text, isFinal: false) + append(line) + volatileIndex[speaker] = line.id } } @@ -67,20 +76,20 @@ final class SubtitleStore { @discardableResult func commitFinal(speaker: Speaker, text: String) -> UUID { pendingVolatile[speaker] = nil // 定稿后丢弃陈旧暂存中间态 - if let i = volatileIndex[speaker] { + if let id = volatileIndex[speaker], let i = index(of: id) { lines[i].original = text lines[i].isFinal = true volatileIndex[speaker] = nil - return lines[i].id + return id } else { let line = SubtitleLine(speaker: speaker, original: text, isFinal: true) - lines.append(line) + append(line) return line.id } } func attachTranslation(id: UUID, zh: String) { - guard let i = lines.firstIndex(where: { $0.id == id }) else { return } + guard let i = index(of: id) else { return } lines[i].translated = zh lines[i].translationFailed = false // 成功则清除任何旧的失败标记 lines[i].translationProvisional = false // 定稿译文,不再是临时半句 @@ -89,7 +98,7 @@ final class SubtitleStore { /// 翻译尝试失败:打标记,UI 据此回退显原文而非永久「翻译中…」或残留的半句译文。 /// 未翻译(nil)或仅有中间态临时译文(provisional)时都视为失败并清掉临时译文。 func markTranslationFailed(id: UUID) { - guard let i = lines.firstIndex(where: { $0.id == id }) else { return } + guard let i = index(of: id) else { return } if lines[i].translated == nil || lines[i].translationProvisional { lines[i].translated = nil lines[i].translationProvisional = false @@ -99,15 +108,35 @@ final class SubtitleStore { /// 当前某 speaker 未定稿中间态的原文(供边说边译读取);无则 nil。 func currentVolatileText(speaker: Speaker) -> String? { - guard let i = volatileIndex[speaker] else { return nil } + guard let id = volatileIndex[speaker], let i = index(of: id) else { return nil } return lines[i].original } /// 回填中间态译文:仅当该 speaker 的中间态行仍存在、仍未定稿、且原文未变(== sourceText)时才应用, /// 避免把过期片段的译文贴到已被新内容替换或已定稿的行上。 func attachVolatileTranslation(speaker: Speaker, sourceText: String, zh: String) { - guard let i = volatileIndex[speaker], !lines[i].isFinal, lines[i].original == sourceText else { return } + guard let id = volatileIndex[speaker], let i = index(of: id), + !lines[i].isFinal, lines[i].original == sourceText else { return } lines[i].translated = zh lines[i].translationProvisional = true // 半句临时译文;终句译文到位或失败时会被覆盖/清除 } + + // MARK: - 内部:索引维护 + 截断 + + private func index(of id: UUID) -> Int? { indexByID[id] } + + /// 追加一行,维护 indexByID,并在超限时截断。 + private func append(_ line: SubtitleLine) { + lines.append(line) + indexByID[line.id] = lines.count - 1 + trimIfNeeded() + } + + /// 超过上限则丢弃最旧的行并重建 indexByID。当前未定稿行总在尾部,不会被丢。 + private func trimIfNeeded() { + guard lines.count > maxLines else { return } + lines.removeFirst(lines.count - maxLines) + indexByID.removeAll(keepingCapacity: true) + for (i, line) in lines.enumerated() { indexByID[line.id] = i } + } } diff --git a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift index 5eed68b..fc9145a 100644 --- a/Tests/LiveSubtitleTests/SubtitleStoreTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleStoreTests.swift @@ -167,4 +167,25 @@ final class SubtitleStoreTests: XCTestCase { XCTAssertEqual(s.lines[0].translated, "我认为我们应该取消。") XCTAssertFalse(s.lines[0].translationProvisional) } + + // MARK: - 保留上限 + O(1) 索引在截断后仍正确 + + func testLinesCappedAndLatestStillLocatable() { + let s = SubtitleStore() + var lastID = UUID() + for k in 0..<2100 { lastID = s.commitFinal(speaker: .other, text: "line \(k)") } + XCTAssertLessThanOrEqual(s.lines.count, 2000) // 超限被截断 + XCTAssertEqual(s.lines.last?.original, "line 2099") // 最新行保留 + s.attachTranslation(id: lastID, zh: "最后一行") // 截断后 id→index 仍能定位 + XCTAssertEqual(s.lines.last?.translated, "最后一行") + } + + func testVolatileSurvivesTrim() { + let s = SubtitleStore() + for k in 0..<2100 { _ = s.commitFinal(speaker: .other, text: "f\(k)") } + s.upsertVolatile(speaker: .me, text: "hello") // 新中间态在尾部,不应被截断影响 + XCTAssertEqual(s.currentVolatileText(speaker: .me), "hello") + s.attachVolatileTranslation(speaker: .me, sourceText: "hello", zh: "你好") + XCTAssertEqual(s.lines.last(where: { $0.speaker == .me })?.translated, "你好") + } } From 5bfdfe8be72dc60cef443df324ca82421495c9b2 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 14:01:18 +1200 Subject: [PATCH 16/23] fix(overlay): bar no longer clips at large font; in-place property updates (no rebuild flicker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bar panel is now 300pt tall with the subtitle bubble bottom-anchored (Spacer + bottom alignment, transparent above). Fixes clipping of the newest line at fontSize 32 + bilingual + 3 lines; position stays pinned to the screen bottom. - OverlayController only rebuilds the panel on a bar⇄mini mode change. pinned / barWidth / layoutEditing now update the existing panel in place (level, width, ignoresMouseEvents/movable). Kills the per-slider-step bar rebuild flicker and the pointless mini-panel rebuild when toggling layoutEditing in mini mode. Visual layout (bottom-anchor + in-place width resize) still wants a hands-on look; logic verified, build + 31 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../Overlay/OverlayController.swift | 38 ++++++++++++++++++- .../Overlay/SubtitleBarView.swift | 34 +++++++++-------- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index 4c9e2c4..cf30a76 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -12,6 +12,8 @@ final class OverlayController { private var active = false /// 观察代次:show/hide 时自增,使 hide 前武装的旧 withObservationTracking 回调失效(无法 cancel,只能靠代次作废)。 private var generation = 0 + /// 当前 panel 的形态;用于判断观察变化是否需要整块重建(仅形态变才重建)。 + private var currentMode: OverlayMode? func show(store: SubtitleStore) { self.store = store @@ -27,6 +29,7 @@ final class OverlayController { removeMoveObserver() panel?.orderOut(nil) panel = nil + currentMode = nil } /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 @@ -42,12 +45,38 @@ final class OverlayController { } onChange: { Task { @MainActor in guard self.active, gen == self.generation else { return } - self.applyMode() + self.reconfigure() self.observe() } } } + /// 观察回调:仅形态(bar⇄mini)变化才整块重建;pinned/barWidth/layoutEditing 变化就地改属性, + /// 避免每次调 Pin/拖宽度/切编辑态都重建 NSPanel(闪烁 + 丢滚动位置)。 + private func reconfigure() { + guard active, let store else { return } + if currentMode != store.overlayMode || panel == nil { + applyMode() + } else { + updateInPlace(store) + } + } + + private func updateInPlace(_ store: SubtitleStore) { + guard let p = panel else { return } + p.level = store.pinned ? .screenSaver : .floating + if store.overlayMode == .bar { + p.ignoresMouseEvents = !store.layoutEditing + p.isMovableByWindowBackground = store.layoutEditing + if abs(p.frame.size.width - store.barWidth) > 0.5 { + var f = p.frame + f.size.width = store.barWidth // 就地改宽:内容随 autoresize + SwiftUI 重排,不重建 + p.setFrame(f, display: true) + } + } + // mini:layoutEditing/barWidth 与它无关,无需任何重建(消除小窗模式下切编辑态的白重建) + } + private func applyMode() { guard active, let store else { return } removeMoveObserver() @@ -56,12 +85,17 @@ final class OverlayController { p.level = store.pinned ? .screenSaver : .floating p.orderFrontRegardless() panel = p + currentMode = store.overlayMode } private func makeBarPanel(_ store: SubtitleStore) -> NSPanel { let width = store.barWidth + // 高度取足够容纳最大字号(32)+ 双语 + 3 行的量;内容在 SubtitleBarView 内底对齐, + // 多出的空间透明不可见,字幕始终贴底不被裁。 + let height: CGFloat = 300 let host = NSHostingView(rootView: SubtitleBarView(store: store)) - host.frame = NSRect(x: 0, y: 0, width: width, height: 200) + host.frame = NSRect(x: 0, y: 0, width: width, height: height) + host.autoresizingMask = [.width, .height] // 就地改宽时内容跟随 let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) p.isFloatingPanel = true diff --git a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift index 025f511..7ce54f4 100644 --- a/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift +++ b/Sources/LiveSubtitle/Overlay/SubtitleBarView.swift @@ -43,22 +43,26 @@ struct SubtitleLineRow: View { struct SubtitleBarView: View { var store: SubtitleStore var body: some View { - VStack(alignment: .leading, spacing: 12) { - ForEach(store.lines.suffix(3)) { line in - SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) + VStack(spacing: 0) { + Spacer(minLength: 0) // 把字幕气泡顶到 panel 底部,上方多余高度透明 + VStack(alignment: .leading, spacing: 12) { + ForEach(store.lines.suffix(3)) { line in + SubtitleLineRow(line: line, displayMode: store.displayMode, fontSize: store.fontSize) + } } + .padding(18) + .frame(width: store.barWidth, alignment: .leading) + .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .strokeBorder( + store.layoutEditing ? Color.yellow.opacity(0.9) : Color.white.opacity(0.1), + style: store.layoutEditing + ? StrokeStyle(lineWidth: 2.5, dash: [8, 5]) + : StrokeStyle(lineWidth: 1) + ) + ) } - .padding(18) - .frame(width: store.barWidth, alignment: .leading) - .background(.black.opacity(store.opacity), in: RoundedRectangle(cornerRadius: 18)) - .overlay( - RoundedRectangle(cornerRadius: 18) - .strokeBorder( - store.layoutEditing ? Color.yellow.opacity(0.9) : Color.white.opacity(0.1), - style: store.layoutEditing - ? StrokeStyle(lineWidth: 2.5, dash: [8, 5]) - : StrokeStyle(lineWidth: 1) - ) - ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } From 9f1e86c9a94501b16d55bdd0ff4a201814f12dc0 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 14:15:29 +1200 Subject: [PATCH 17/23] feat(overlay): appearance controls move to a gear panel beside the subtitle bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native NSMenu can't render sliders — SwiftUI degraded 透明度/字号/字幕条宽度 into "Decrement/Increment" submenus. Moved them out of the menu bar into a dedicated floating gear panel next to the overlay: - AppearanceControlView: collapsed = gear button; expanded = real sliders for opacity / font size / bar width (bar width only in bar mode). - Its own NSPanel, because the subtitle bar is click-through window-wide and a gear drawn on it could never be clicked. Draggable, position persisted (ls.gearX/Y), default sits to the right of the bar, follows Pin level, off-screen coords fall back. - Expanded state persisted (ls.appearanceExpanded); size syncs in place, top edge anchored so expanding doesn't run off the screen bottom. - Menu keeps only what NSMenu renders correctly (buttons/toggles/pickers) plus a toggle to expand the gear panel. +2 settings assertions (31 tests). Panel placement/feel needs a hands-on look. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 10 +-- .../LiveSubtitle/Models/SubtitleStore.swift | 3 + .../Overlay/AppearanceControlView.swift | 57 +++++++++++++++ .../Overlay/OverlayController.swift | 71 +++++++++++++++++++ .../SubtitleSettingsTests.swift | 3 + 5 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 Sources/LiveSubtitle/Overlay/AppearanceControlView.swift diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 8ec6d48..2ec5c95 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -38,15 +38,9 @@ struct LiveSubtitleApp: App { Toggle("置顶 Pin", isOn: $s.pinned) Divider() - Text("透明度") - Slider(value: $s.opacity, in: 0.4...1.0) - Text("字号") - Slider(value: $s.fontSize, in: 16...32, step: 1) - Divider() - + // 透明度/字号/字幕条宽度在字幕条旁的齿轮浮层里调(原生菜单渲染不了滑块) + Toggle("外观面板(齿轮展开)", isOn: $s.appearanceExpanded) Toggle("布局编辑(拖动字幕条)", isOn: $s.layoutEditing) - Text("字幕条宽度") - Slider(value: $s.barWidth, in: 600...1400, step: 20) Divider() Button("整理并导出到 Obsidian") { exportToObsidian() } diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index ddd20ea..77b450c 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -17,6 +17,8 @@ final class SubtitleStore { var obsidianVaultPath: String { didSet { defaults.set(obsidianVaultPath, forKey: "ls.vaultPath") } } /// 边说边译:对未定稿的中间态也翻译(降延迟,代价是译文会随句子生长而跳变)。 var translateVolatile: Bool { didSet { defaults.set(translateVolatile, forKey: "ls.translateVolatile") } } + /// 外观齿轮浮层是否展开(展开=显示透明度/字号/宽度滑块)。 + var appearanceExpanded: Bool { didSet { defaults.set(appearanceExpanded, forKey: "ls.appearanceExpanded") } } /// 布局编辑态,瞬态(不持久化),启动永远 false。 var layoutEditing: Bool = false @@ -46,6 +48,7 @@ final class SubtitleStore { deepSeekAPIKey = defaults.string(forKey: "ls.deepSeekKey") ?? "" obsidianVaultPath = defaults.string(forKey: "ls.vaultPath") ?? "" translateVolatile = defaults.object(forKey: "ls.translateVolatile") as? Bool ?? true + appearanceExpanded = defaults.bool(forKey: "ls.appearanceExpanded") layoutEditing = false } diff --git a/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift b/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift new file mode 100644 index 0000000..3a38f5c --- /dev/null +++ b/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// 外观控制浮层:齿轮收起 / 展开为 透明度·字号·(字幕条)宽度 滑块。 +/// 独立可点击小窗——字幕条要保持点击穿透,故这些控件必须自成一窗(见 OverlayController.makeGearPanel)。 +struct AppearanceControlView: View { + @Bindable var store: SubtitleStore + + var body: some View { + Group { + if store.appearanceExpanded { expanded } else { collapsed } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var collapsed: some View { + Button { store.appearanceExpanded = true } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7), in: Circle()) + .overlay(Circle().stroke(.white.opacity(0.12))) + } + .buttonStyle(.plain) + .help("外观设置") + } + + private var expanded: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "gearshape.fill").font(.system(size: 12)).foregroundStyle(.white.opacity(0.8)) + Text("外观").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) + Spacer() + Button { store.appearanceExpanded = false } label: { + Image(systemName: "chevron.down").font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white.opacity(0.7)) + }.buttonStyle(.plain).help("收起") + } + row("透明度") { Slider(value: $store.opacity, in: 0.4...1.0) } + row("字号 \(Int(store.fontSize))") { Slider(value: $store.fontSize, in: 16...32, step: 1) } + if store.overlayMode == .bar { + row("字幕条宽度 \(Int(store.barWidth))") { Slider(value: $store.barWidth, in: 600...1400, step: 20) } + } + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(.black.opacity(0.82), in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(.white.opacity(0.12))) + } + + private func row<Content: View>(_ title: String, @ViewBuilder _ control: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.system(size: 11)).foregroundStyle(.white.opacity(0.7)) + control() + } + } +} diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index cf30a76..f807dac 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -4,6 +4,8 @@ import SwiftUI @MainActor final class OverlayController { private var panel: NSPanel? + private var gearPanel: NSPanel? // 外观控制小窗(齿轮/滑块),独立可点击 + private var gearMoveObserver: NSObjectProtocol? private var store: SubtitleStore? private var moveObserver: NSObjectProtocol? private var resizeObserver: NSObjectProtocol? @@ -20,6 +22,7 @@ final class OverlayController { active = true generation &+= 1 applyMode() + showGear(store) observe() } @@ -30,6 +33,9 @@ final class OverlayController { panel?.orderOut(nil) panel = nil currentMode = nil + if let o = gearMoveObserver { NotificationCenter.default.removeObserver(o); gearMoveObserver = nil } + gearPanel?.orderOut(nil) + gearPanel = nil } /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 @@ -42,6 +48,7 @@ final class OverlayController { _ = store.pinned _ = store.barWidth _ = store.layoutEditing + _ = store.appearanceExpanded } onChange: { Task { @MainActor in guard self.active, gen == self.generation else { return } @@ -60,6 +67,7 @@ final class OverlayController { } else { updateInPlace(store) } + syncGearSize(store) // 齿轮窗随展开/收起改尺寸,并保持置顶层级 } private func updateInPlace(_ store: SubtitleStore) { @@ -181,6 +189,69 @@ final class OverlayController { return p } + // MARK: - 外观控制小窗(齿轮) + + /// 齿轮窗尺寸:收起=小圆按钮,展开=滑块面板(bar 模式多一条宽度滑块)。 + private func gearSize(_ store: SubtitleStore) -> NSSize { + guard store.appearanceExpanded else { return NSSize(width: 40, height: 40) } + return NSSize(width: 260, height: store.overlayMode == .bar ? 190 : 150) + } + + /// 外观控制必须自成一窗:字幕条整窗点击穿透,无法只让齿轮那块可点。 + private func showGear(_ store: SubtitleStore) { + let size = gearSize(store) + let host = NSHostingView(rootView: AppearanceControlView(store: store)) + host.frame = NSRect(origin: .zero, size: size) + host.autoresizingMask = [.width, .height] + let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, defer: false) + p.isFloatingPanel = true + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = true + p.isMovableByWindowBackground = true // 齿轮窗可拖到顺手的位置 + p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + p.contentView = host + let gearDefault: NSPoint = { + if let screen = NSScreen.main { + let f = screen.visibleFrame + return NSPoint(x: f.midX + store.barWidth / 2 + 12, y: f.minY + 60) // 默认贴字幕条右侧 + } + return NSPoint(x: 120, y: 120) + }() + if let x = defaults.object(forKey: "ls.gearX") as? Double, + let y = defaults.object(forKey: "ls.gearY") as? Double { + p.setFrameOrigin(NSPoint(x: x, y: y)) + ensureVisible(p, fallback: gearDefault) + } else { + p.setFrameOrigin(gearDefault) + } + p.level = store.pinned ? .screenSaver : .floating + p.orderFrontRegardless() + gearPanel = p + gearMoveObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let origin = self.gearPanel?.frame.origin else { return } + self.defaults.set(Double(origin.x), forKey: "ls.gearX") + self.defaults.set(Double(origin.y), forKey: "ls.gearY") + } + } + } + + /// 展开/收起或形态变化后同步齿轮窗尺寸(就地改,不重建)。左上角锚定,避免展开时越过屏底。 + private func syncGearSize(_ store: SubtitleStore) { + guard let p = gearPanel else { return } + p.level = store.pinned ? .screenSaver : .floating + let size = gearSize(store) + guard abs(p.frame.width - size.width) > 0.5 || abs(p.frame.height - size.height) > 0.5 else { return } + var f = p.frame + f.origin.y += f.size.height - size.height // 保持顶边不动 + f.size = size + p.setFrame(f, display: true) + ensureVisible(p, fallback: f.origin) + } + private func removeMoveObserver() { if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } if let o = resizeObserver { NotificationCenter.default.removeObserver(o); resizeObserver = nil } diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift index 3871dea..0ea6f38 100644 --- a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -19,6 +19,7 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s.obsidianVaultPath, "") XCTAssertFalse(s.layoutEditing) XCTAssertTrue(s.translateVolatile) // 默认开(边说边译) + XCTAssertFalse(s.appearanceExpanded) // 默认收起(只显齿轮) } func testSettingsPersistAcrossInstances() { @@ -33,6 +34,7 @@ final class SubtitleSettingsTests: XCTestCase { s1.deepSeekAPIKey = "sk-test-123" s1.obsidianVaultPath = "/Users/me/Vault" s1.translateVolatile = false + s1.appearanceExpanded = true let s2 = SubtitleStore(defaults: suite) XCTAssertEqual(s2.displayMode, .translatedOnly) XCTAssertEqual(s2.overlayMode, .mini) @@ -43,6 +45,7 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s2.deepSeekAPIKey, "sk-test-123") XCTAssertEqual(s2.obsidianVaultPath, "/Users/me/Vault") XCTAssertFalse(s2.translateVolatile) + XCTAssertTrue(s2.appearanceExpanded) } func testLayoutEditingIsTransient() { From 7c52a9a5799cbcd53e41f99e86536719675ac953 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 14:26:45 +1200 Subject: [PATCH 18/23] feat(menu): appearance controls back in the menu bar via .window style; drop the gear panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gear panel beside the overlay looked bad, so revert it and solve the original problem (native NSMenu can't render sliders) the other way: switch MenuBarExtra to .menuBarExtraStyle(.window), which renders the dropdown as a real SwiftUI panel. Sliders work there, so 透明度/字号/字幕条宽度 live in the menu again — each with a live value readout; bar width only shows in bar mode. - Deleted AppearanceControlView; removed gearPanel/gearMoveObserver/showGear/ syncGearSize from OverlayController and appearanceExpanded from the store. - Menu redone as a 280pt panel: prominent start/stop, segmented 显示/形态 pickers, toggles, sliders, export + 设置…/退出. - clampToScreen stays (bar/mini positioning still needs it). Also fixes the off-screen overflow the user hit: the old ensureVisible only checked intersects(), so a mostly-off-screen panel passed, and the expand path passed its own overflowing origin as the fallback. clampToScreen now fits the whole frame inside the best-overlapping screen's visibleFrame, called on every position/size change path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 81 +++++++++++---- .../LiveSubtitle/Models/SubtitleStore.swift | 3 - .../Overlay/AppearanceControlView.swift | 57 ----------- .../Overlay/OverlayController.swift | 99 ++++--------------- .../SubtitleSettingsTests.swift | 3 - 5 files changed, 83 insertions(+), 160 deletions(-) delete mode 100644 Sources/LiveSubtitle/Overlay/AppearanceControlView.swift diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 2ec5c95..417b072 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -18,46 +18,89 @@ struct LiveSubtitleApp: App { @Environment(\.openSettings) private var openSettings var body: some Scene { + // .window 样式:下拉是真正的 SwiftUI 面板,滑块能正常渲染并拖动 + // (默认 .menu 走原生 NSMenu,Slider 会退化成 Decrement/Increment 子菜单) MenuBarExtra("LiveSubtitle", systemImage: "captions.bubble") { - Group { - @Bindable var s = store + menuPanel + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView(store: store) + } + } + + @ViewBuilder private var menuPanel: some View { + @Bindable var s = store + VStack(alignment: .leading, spacing: 12) { Button(running ? "停止字幕" : "开始字幕") { toggle() } - if !status.isEmpty { Text(status).font(.caption) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + if !status.isEmpty { + Text(status).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + Divider() Picker("显示", selection: $s.displayMode) { Text("原文").tag(DisplayMode.originalOnly) Text("双语").tag(DisplayMode.both) Text("译文").tag(DisplayMode.translatedOnly) - } - Toggle("边说边译(中间态)", isOn: $s.translateVolatile) + }.pickerStyle(.segmented) Picker("形态", selection: $s.overlayMode) { Text("字幕条").tag(OverlayMode.bar) Text("小窗").tag(OverlayMode.mini) - } + }.pickerStyle(.segmented) + + Toggle("边说边译(中间态)", isOn: $s.translateVolatile) Toggle("置顶 Pin", isOn: $s.pinned) + Toggle("布局编辑(拖动字幕条)", isOn: $s.layoutEditing) + Divider() - // 透明度/字号/字幕条宽度在字幕条旁的齿轮浮层里调(原生菜单渲染不了滑块) - Toggle("外观面板(齿轮展开)", isOn: $s.appearanceExpanded) - Toggle("布局编辑(拖动字幕条)", isOn: $s.layoutEditing) + slider("透明度", value: $s.opacity, in: 0.4...1.0, display: "\(Int(store.opacity * 100))%") + slider("字号", value: $s.fontSize, in: 16...32, step: 1, display: "\(Int(store.fontSize))") + if store.overlayMode == .bar { + slider("字幕条宽度", value: $s.barWidth, in: 600...1400, step: 20, display: "\(Int(store.barWidth))") + } + Divider() Button("整理并导出到 Obsidian") { exportToObsidian() } .disabled(isExporting) - if !exportStatus.isEmpty { Text(exportStatus).font(.caption) } - Button("设置…") { - NSApp.activate(ignoringOtherApps: true) // 菜单栏触发时确保设置窗口置前获焦 - openSettings() + .frame(maxWidth: .infinity) + if !exportStatus.isEmpty { + Text(exportStatus).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + HStack { + Button("设置…") { + NSApp.activate(ignoringOtherApps: true) // 菜单栏触发时确保设置窗口置前获焦 + openSettings() + } + Spacer() + Button("退出") { NSApplication.shared.terminate(nil) } } - Divider() - - Button("退出") { NSApplication.shared.terminate(nil) } - } } + .padding(14) + .frame(width: 280) + } - Settings { - SettingsView(store: store) + /// 带右侧数值的紧凑滑块行(.window 样式下 Slider 正常可用)。 + @ViewBuilder private func slider(_ title: String, value: Binding<Double>, + in range: ClosedRange<Double>, step: Double? = nil, + display: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(title).font(.caption) + Spacer() + Text(display).font(.caption).foregroundStyle(.secondary).monospacedDigit() + } + if let step { + Slider(value: value, in: range, step: step) + } else { + Slider(value: value, in: range) + } } } diff --git a/Sources/LiveSubtitle/Models/SubtitleStore.swift b/Sources/LiveSubtitle/Models/SubtitleStore.swift index 77b450c..ddd20ea 100644 --- a/Sources/LiveSubtitle/Models/SubtitleStore.swift +++ b/Sources/LiveSubtitle/Models/SubtitleStore.swift @@ -17,8 +17,6 @@ final class SubtitleStore { var obsidianVaultPath: String { didSet { defaults.set(obsidianVaultPath, forKey: "ls.vaultPath") } } /// 边说边译:对未定稿的中间态也翻译(降延迟,代价是译文会随句子生长而跳变)。 var translateVolatile: Bool { didSet { defaults.set(translateVolatile, forKey: "ls.translateVolatile") } } - /// 外观齿轮浮层是否展开(展开=显示透明度/字号/宽度滑块)。 - var appearanceExpanded: Bool { didSet { defaults.set(appearanceExpanded, forKey: "ls.appearanceExpanded") } } /// 布局编辑态,瞬态(不持久化),启动永远 false。 var layoutEditing: Bool = false @@ -48,7 +46,6 @@ final class SubtitleStore { deepSeekAPIKey = defaults.string(forKey: "ls.deepSeekKey") ?? "" obsidianVaultPath = defaults.string(forKey: "ls.vaultPath") ?? "" translateVolatile = defaults.object(forKey: "ls.translateVolatile") as? Bool ?? true - appearanceExpanded = defaults.bool(forKey: "ls.appearanceExpanded") layoutEditing = false } diff --git a/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift b/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift deleted file mode 100644 index 3a38f5c..0000000 --- a/Sources/LiveSubtitle/Overlay/AppearanceControlView.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -/// 外观控制浮层:齿轮收起 / 展开为 透明度·字号·(字幕条)宽度 滑块。 -/// 独立可点击小窗——字幕条要保持点击穿透,故这些控件必须自成一窗(见 OverlayController.makeGearPanel)。 -struct AppearanceControlView: View { - @Bindable var store: SubtitleStore - - var body: some View { - Group { - if store.appearanceExpanded { expanded } else { collapsed } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var collapsed: some View { - Button { store.appearanceExpanded = true } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.7), in: Circle()) - .overlay(Circle().stroke(.white.opacity(0.12))) - } - .buttonStyle(.plain) - .help("外观设置") - } - - private var expanded: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 6) { - Image(systemName: "gearshape.fill").font(.system(size: 12)).foregroundStyle(.white.opacity(0.8)) - Text("外观").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) - Spacer() - Button { store.appearanceExpanded = false } label: { - Image(systemName: "chevron.down").font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white.opacity(0.7)) - }.buttonStyle(.plain).help("收起") - } - row("透明度") { Slider(value: $store.opacity, in: 0.4...1.0) } - row("字号 \(Int(store.fontSize))") { Slider(value: $store.fontSize, in: 16...32, step: 1) } - if store.overlayMode == .bar { - row("字幕条宽度 \(Int(store.barWidth))") { Slider(value: $store.barWidth, in: 600...1400, step: 20) } - } - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(.black.opacity(0.82), in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(.white.opacity(0.12))) - } - - private func row<Content: View>(_ title: String, @ViewBuilder _ control: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.system(size: 11)).foregroundStyle(.white.opacity(0.7)) - control() - } - } -} diff --git a/Sources/LiveSubtitle/Overlay/OverlayController.swift b/Sources/LiveSubtitle/Overlay/OverlayController.swift index f807dac..bb920f6 100644 --- a/Sources/LiveSubtitle/Overlay/OverlayController.swift +++ b/Sources/LiveSubtitle/Overlay/OverlayController.swift @@ -4,8 +4,6 @@ import SwiftUI @MainActor final class OverlayController { private var panel: NSPanel? - private var gearPanel: NSPanel? // 外观控制小窗(齿轮/滑块),独立可点击 - private var gearMoveObserver: NSObjectProtocol? private var store: SubtitleStore? private var moveObserver: NSObjectProtocol? private var resizeObserver: NSObjectProtocol? @@ -22,7 +20,6 @@ final class OverlayController { active = true generation &+= 1 applyMode() - showGear(store) observe() } @@ -33,9 +30,6 @@ final class OverlayController { panel?.orderOut(nil) panel = nil currentMode = nil - if let o = gearMoveObserver { NotificationCenter.default.removeObserver(o); gearMoveObserver = nil } - gearPanel?.orderOut(nil) - gearPanel = nil } /// 观察 overlayMode / pinned / barWidth / layoutEditing,变化即重配并重新武装观察。 @@ -48,7 +42,6 @@ final class OverlayController { _ = store.pinned _ = store.barWidth _ = store.layoutEditing - _ = store.appearanceExpanded } onChange: { Task { @MainActor in guard self.active, gen == self.generation else { return } @@ -67,7 +60,6 @@ final class OverlayController { } else { updateInPlace(store) } - syncGearSize(store) // 齿轮窗随展开/收起改尺寸,并保持置顶层级 } private func updateInPlace(_ store: SubtitleStore) { @@ -80,6 +72,7 @@ final class OverlayController { var f = p.frame f.size.width = store.barWidth // 就地改宽:内容随 autoresize + SwiftUI 重排,不重建 p.setFrame(f, display: true) + clampToScreen(p) // 加宽可能顶出右边界 } } // mini:layoutEditing/barWidth 与它无关,无需任何重建(消除小窗模式下切编辑态的白重建) @@ -125,10 +118,10 @@ final class OverlayController { if let x = defaults.object(forKey: "ls.barX") as? Double, let y = defaults.object(forKey: "ls.barY") as? Double { p.setFrameOrigin(NSPoint(x: x, y: y)) - ensureVisible(p, fallback: barDefault) } else { p.setFrameOrigin(barDefault) } + clampToScreen(p) moveObserver = NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in MainActor.assumeIsolated { @@ -166,10 +159,10 @@ final class OverlayController { if let x = defaults.object(forKey: "ls.miniX") as? Double, let y = defaults.object(forKey: "ls.miniY") as? Double { p.setFrameOrigin(NSPoint(x: x, y: y)) - ensureVisible(p, fallback: miniDefault) } else { p.setFrameOrigin(miniDefault) } + clampToScreen(p) moveObserver = NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in MainActor.assumeIsolated { @@ -189,78 +182,28 @@ final class OverlayController { return p } - // MARK: - 外观控制小窗(齿轮) - - /// 齿轮窗尺寸:收起=小圆按钮,展开=滑块面板(bar 模式多一条宽度滑块)。 - private func gearSize(_ store: SubtitleStore) -> NSSize { - guard store.appearanceExpanded else { return NSSize(width: 40, height: 40) } - return NSSize(width: 260, height: store.overlayMode == .bar ? 190 : 150) - } - - /// 外观控制必须自成一窗:字幕条整窗点击穿透,无法只让齿轮那块可点。 - private func showGear(_ store: SubtitleStore) { - let size = gearSize(store) - let host = NSHostingView(rootView: AppearanceControlView(store: store)) - host.frame = NSRect(origin: .zero, size: size) - host.autoresizingMask = [.width, .height] - let p = NSPanel(contentRect: host.frame, styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, defer: false) - p.isFloatingPanel = true - p.backgroundColor = .clear - p.isOpaque = false - p.hasShadow = true - p.isMovableByWindowBackground = true // 齿轮窗可拖到顺手的位置 - p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - p.contentView = host - let gearDefault: NSPoint = { - if let screen = NSScreen.main { - let f = screen.visibleFrame - return NSPoint(x: f.midX + store.barWidth / 2 + 12, y: f.minY + 60) // 默认贴字幕条右侧 - } - return NSPoint(x: 120, y: 120) - }() - if let x = defaults.object(forKey: "ls.gearX") as? Double, - let y = defaults.object(forKey: "ls.gearY") as? Double { - p.setFrameOrigin(NSPoint(x: x, y: y)) - ensureVisible(p, fallback: gearDefault) - } else { - p.setFrameOrigin(gearDefault) - } - p.level = store.pinned ? .screenSaver : .floating - p.orderFrontRegardless() - gearPanel = p - gearMoveObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didMoveNotification, object: p, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { - guard let self, let origin = self.gearPanel?.frame.origin else { return } - self.defaults.set(Double(origin.x), forKey: "ls.gearX") - self.defaults.set(Double(origin.y), forKey: "ls.gearY") - } - } - } - - /// 展开/收起或形态变化后同步齿轮窗尺寸(就地改,不重建)。左上角锚定,避免展开时越过屏底。 - private func syncGearSize(_ store: SubtitleStore) { - guard let p = gearPanel else { return } - p.level = store.pinned ? .screenSaver : .floating - let size = gearSize(store) - guard abs(p.frame.width - size.width) > 0.5 || abs(p.frame.height - size.height) > 0.5 else { return } - var f = p.frame - f.origin.y += f.size.height - size.height // 保持顶边不动 - f.size = size - p.setFrame(f, display: true) - ensureVisible(p, fallback: f.origin) - } - private func removeMoveObserver() { if let o = moveObserver { NotificationCenter.default.removeObserver(o); moveObserver = nil } if let o = resizeObserver { NotificationCenter.default.removeObserver(o); resizeObserver = nil } } - /// 恢复的坐标可能因显示器拔插/分辨率变化落到屏外(浮窗不可见且无恢复入口); - /// 不与任一屏可见区相交则回退到默认位置。 - private func ensureVisible(_ p: NSPanel, fallback: NSPoint) { - let onScreen = NSScreen.screens.contains { $0.visibleFrame.intersects(p.frame) } - if !onScreen { p.setFrameOrigin(fallback) } + /// 把浮窗【完整】夹进某个屏幕的可见区——只判"有交集"是不够的(一角在屏内、其余溢出也会通过)。 + /// 覆盖溢出来源:恢复的旧坐标(拔显示器/改分辨率)、字幕条加宽、小窗尺寸恢复。 + private func clampToScreen(_ p: NSPanel) { + let frame = p.frame + // 取与窗口重叠最多的屏;完全在屏外时退到主屏 + let screen = NSScreen.screens.max { + overlapArea($0.visibleFrame, frame) < overlapArea($1.visibleFrame, frame) + } ?? NSScreen.main + guard let vf = screen?.visibleFrame else { return } + var origin = frame.origin + origin.x = frame.width <= vf.width ? min(max(origin.x, vf.minX), vf.maxX - frame.width) : vf.minX + origin.y = frame.height <= vf.height ? min(max(origin.y, vf.minY), vf.maxY - frame.height) : vf.minY + if origin != frame.origin { p.setFrameOrigin(origin) } + } + + private func overlapArea(_ a: NSRect, _ b: NSRect) -> CGFloat { + let r = a.intersection(b) + return r.isNull ? 0 : r.width * r.height } } diff --git a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift index 0ea6f38..3871dea 100644 --- a/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift +++ b/Tests/LiveSubtitleTests/SubtitleSettingsTests.swift @@ -19,7 +19,6 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s.obsidianVaultPath, "") XCTAssertFalse(s.layoutEditing) XCTAssertTrue(s.translateVolatile) // 默认开(边说边译) - XCTAssertFalse(s.appearanceExpanded) // 默认收起(只显齿轮) } func testSettingsPersistAcrossInstances() { @@ -34,7 +33,6 @@ final class SubtitleSettingsTests: XCTestCase { s1.deepSeekAPIKey = "sk-test-123" s1.obsidianVaultPath = "/Users/me/Vault" s1.translateVolatile = false - s1.appearanceExpanded = true let s2 = SubtitleStore(defaults: suite) XCTAssertEqual(s2.displayMode, .translatedOnly) XCTAssertEqual(s2.overlayMode, .mini) @@ -45,7 +43,6 @@ final class SubtitleSettingsTests: XCTestCase { XCTAssertEqual(s2.deepSeekAPIKey, "sk-test-123") XCTAssertEqual(s2.obsidianVaultPath, "/Users/me/Vault") XCTAssertFalse(s2.translateVolatile) - XCTAssertTrue(s2.appearanceExpanded) } func testLayoutEditingIsTransient() { From 97cc9dd061b80ba2d4102fbd1502ba0610af1cb0 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 14:33:18 +1200 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20app=20icon=20=E2=80=94=20generate?= =?UTF-8?q?d=20.icns=20wired=20into=20the=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/make-icon.swift draws the icon with plain AppKit (no deps, no design tool): indigo gradient squircle + white speech bubble, three interchangeable glyph styles (bars / cjk「文」/ mixed). Each size is drawn independently rather than downscaled, so 16px stays legible. Emits a full 10-size iconset plus preview/variant sheets. build-app.sh generates build/AppIcon.icns when missing (ICON_STYLE, default cjk), copies it into Contents/Resources and declares CFBundleIconFile. Verified: icns unpacks back to all 10 sizes, bundle still ad-hoc signs. Note: LSUIElement app — this icon shows in Finder/Spotlight/Get Info, not the Dock; the menu bar keeps the monochrome SF Symbol. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- scripts/build-app.sh | 12 ++- scripts/make-icon.swift | 200 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 scripts/make-icon.swift diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 49a1619..b702b61 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -6,8 +6,17 @@ CONF="${1:-debug}" swift build -c "$CONF" BIN=".build/$CONF/LiveSubtitle" APP="build/LiveSubtitle.app" -rm -rf "$APP"; mkdir -p "$APP/Contents/MacOS" +rm -rf "$APP"; mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/LiveSubtitle" + +# 应用图标:缺失时用 scripts/make-icon.swift 现画(bars / cjk / mixed) +# 换风格: rm build/AppIcon.icns && ICON_STYLE=bars bash scripts/build-app.sh +ICON_STYLE="${ICON_STYLE:-cjk}" +if [ ! -f build/AppIcon.icns ]; then + swift scripts/make-icon.swift build "$ICON_STYLE" >/dev/null + iconutil -c icns build/AppIcon.iconset -o build/AppIcon.icns +fi +cp build/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cat > "$APP/Contents/Info.plist" <<'PLIST' <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> @@ -16,6 +25,7 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' <key>CFBundleExecutable</key><string>LiveSubtitle</string> <key>CFBundleIdentifier</key><string>com.livesubtitle.app</string> <key>CFBundleName</key><string>LiveSubtitle</string> + <key>CFBundleIconFile</key><string>AppIcon</string> <key>CFBundlePackageType</key><string>APPL</string> <key>CFBundleShortVersionString</key><string>0.1</string> <key>LSMinimumSystemVersion</key><string>26.0</string> diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..8fb115a --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,200 @@ +// 生成 LiveSubtitle 应用图标(纯 AppKit 绘制,无外部依赖)。 +// 用法: swift scripts/make-icon.swift [输出目录=build] +// 产出: <out>/AppIcon.iconset/*.png(全尺寸)+ <out>/icon-preview.png(浅/深底多尺寸预览) +// 随后 scripts/build-app.sh 会调 iconutil 打成 AppIcon.icns 放进 bundle。 +// +// 设计:深蓝渐变圆角方 + 白色对话气泡,气泡内两条字幕条 +// —— 上条短而淡(英文原文)、下条长而实(中文译文),对应 app 的双语字幕。 +import AppKit +import Foundation + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "build" + +/// 气泡内的图形:bars=两条字幕条 / cjk=「文」字 / mixed=淡原文条 +「文」 +enum Style: String { case bars, cjk, mixed } +let style = Style(rawValue: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "bars") ?? .bars +let wantVariants = CommandLine.arguments.contains("--variants") + +// MARK: - 绘制(尺寸无关,按 s 归一化) + +func drawText(_ str: String, centeredIn rect: NSRect, size: CGFloat, color: NSColor) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: .bold), + .foregroundColor: color, + ] + let a = NSAttributedString(string: str, attributes: attrs) + let sz = a.size() + a.draw(at: NSPoint(x: rect.midX - sz.width / 2, y: rect.midY - sz.height / 2)) +} + +func drawIcon(_ s: CGFloat, _ style: Style = style) { + let inset = s * 0.075 // macOS 图标惯例:留出四周透明边距 + let rect = NSRect(x: inset, y: inset, width: s - inset * 2, height: s - inset * 2) + let radius = rect.width * 0.2237 // squircle 近似圆角 + + let bg = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + let gradient = NSGradient(colors: [ + NSColor(srgbRed: 0.30, green: 0.37, blue: 1.00, alpha: 1), // 亮靛蓝 + NSColor(srgbRed: 0.05, green: 0.08, blue: 0.26, alpha: 1), // 深夜蓝 + ])! + gradient.draw(in: bg, angle: -62) + + // 对话气泡 + let bw = rect.width * 0.62 + let bh = rect.height * 0.44 + let bx = rect.midX - bw / 2 + let by = rect.midY - bh / 2 + rect.height * 0.05 + NSColor.white.setFill() + NSBezierPath(roundedRect: NSRect(x: bx, y: by, width: bw, height: bh), + xRadius: bh * 0.26, yRadius: bh * 0.26).fill() + let tail = NSBezierPath() // 左下小尾巴 + tail.move(to: NSPoint(x: bx + bw * 0.16, y: by + bh * 0.03)) + tail.line(to: NSPoint(x: bx + bw * 0.36, y: by + bh * 0.03)) + tail.line(to: NSPoint(x: bx + bw * 0.20, y: by - rect.height * 0.11)) + tail.close() + tail.fill() + + // 气泡内容 + let navy = NSColor(srgbRed: 0.08, green: 0.11, blue: 0.30, alpha: 1) + let barH = bh * 0.155 + let padX = bw * 0.14 + let usable = bw - padX * 2 + let bubbleRect = NSRect(x: bx, y: by, width: bw, height: bh) + + func bar(y: CGFloat, widthRatio: CGFloat, alpha: CGFloat) { + navy.withAlphaComponent(alpha).setFill() + NSBezierPath(roundedRect: NSRect(x: bx + padX, y: y, width: usable * widthRatio, height: barH), + xRadius: barH / 2, yRadius: barH / 2).fill() + } + + switch style { + case .bars: + bar(y: by + bh * 0.57, widthRatio: 0.56, alpha: 0.40) // 原文:短、淡 + bar(y: by + bh * 0.25, widthRatio: 0.92, alpha: 1.0) // 译文:长、实 + case .cjk: + drawText("文", centeredIn: bubbleRect, size: bh * 0.66, color: navy) + case .mixed: + bar(y: by + bh * 0.70, widthRatio: 0.62, alpha: 0.38) // 上方淡条 = 英文原文 + drawText("文", centeredIn: NSRect(x: bx, y: by + bh * 0.02, width: bw, height: bh * 0.62), + size: bh * 0.50, color: navy) // 下方「文」= 中文译文 + } +} + +// MARK: - 渲染 + +func makeRep(_ px: Int, _ st: Style = style) -> NSBitmapImageRep { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + rep.size = NSSize(width: px, height: px) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + drawIcon(CGFloat(px), st) // 每个尺寸独立绘制,而非缩放,小图更清晰 + NSGraphicsContext.restoreGraphicsState() + return rep +} + +func pngData(_ rep: NSBitmapImageRep) -> Data { + rep.representation(using: .png, properties: [:])! +} + +func image(_ px: Int) -> NSImage { + let img = NSImage(size: NSSize(width: px, height: px)) + img.addRepresentation(makeRep(px)) + return img +} + +/// 浅底/深底两行、多尺寸并排,用于肉眼检查小尺寸下是否还认得出。 +func previewSheet() -> NSBitmapImageRep { + let sizes = [256, 128, 64, 32, 16] + let pad: CGFloat = 28 + let w = pad + sizes.reduce(CGFloat(0)) { $0 + CGFloat($1) + pad } + let rowH: CGFloat = 256 + pad * 2 + let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(w), pixelsHigh: Int(rowH * 2), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + out.size = NSSize(width: w, height: rowH * 2) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out) + NSColor(srgbRed: 0.95, green: 0.95, blue: 0.97, alpha: 1).setFill() + NSRect(x: 0, y: rowH, width: w, height: rowH).fill() + NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: w, height: rowH).fill() + var x = pad + for s in sizes { + let img = image(s) + img.draw(in: NSRect(x: x, y: rowH + pad, width: CGFloat(s), height: CGFloat(s))) + img.draw(in: NSRect(x: x, y: pad, width: CGFloat(s), height: CGFloat(s))) + x += CGFloat(s) + pad + } + NSGraphicsContext.restoreGraphicsState() + return out +} + +/// 三种风格 × 大/小尺寸并排,用于挑选。 +func variantsSheet() -> NSBitmapImageRep { + let styles: [Style] = [.bars, .cjk, .mixed] + let big = 256, small = 32 + let pad: CGFloat = 34 + let colW = CGFloat(big) + pad + let w = pad + colW * CGFloat(styles.count) + let h = pad * 3 + CGFloat(big) + CGFloat(small) + 34 + let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(w), pixelsHigh: Int(h), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)! + out.size = NSSize(width: w, height: h) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out) + NSColor(srgbRed: 0.95, green: 0.95, blue: 0.97, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: w, height: h).fill() + for (i, st) in styles.enumerated() { + let x = pad + colW * CGFloat(i) + let bigImg = NSImage(size: NSSize(width: big, height: big)) + bigImg.addRepresentation(makeRep(big, st)) + bigImg.draw(in: NSRect(x: x, y: h - pad - CGFloat(big), width: CGFloat(big), height: CGFloat(big))) + let smallImg = NSImage(size: NSSize(width: small, height: small)) + smallImg.addRepresentation(makeRep(small, st)) + smallImg.draw(in: NSRect(x: x + CGFloat(big) / 2 - CGFloat(small) / 2, + y: h - pad * 2 - CGFloat(big) - CGFloat(small), + width: CGFloat(small), height: CGFloat(small))) + drawText(st.rawValue, centeredIn: NSRect(x: x, y: 12, width: CGFloat(big), height: 30), + size: 20, color: NSColor(srgbRed: 0.2, green: 0.2, blue: 0.25, alpha: 1)) + } + NSGraphicsContext.restoreGraphicsState() + return out +} + +// MARK: - 输出 + +let fm = FileManager.default +try? fm.createDirectory(atPath: outDir, withIntermediateDirectories: true) + +if wantVariants { + let p = "\(outDir)/icon-variants.png" + try pngData(variantsSheet()).write(to: URL(fileURLWithPath: p)) + print("variants: \(p)") + exit(0) +} + +let iconset = "\(outDir)/AppIcon.iconset" +try? fm.createDirectory(atPath: iconset, withIntermediateDirectories: true) + +let entries: [(Int, String)] = [ + (16, "icon_16x16"), (32, "icon_16x16@2x"), + (32, "icon_32x32"), (64, "icon_32x32@2x"), + (128, "icon_128x128"), (256, "icon_128x128@2x"), + (256, "icon_256x256"), (512, "icon_256x256@2x"), + (512, "icon_512x512"), (1024, "icon_512x512@2x"), +] +for (px, name) in entries { + let url = URL(fileURLWithPath: "\(iconset)/\(name).png") + try pngData(makeRep(px)).write(to: url) +} +let previewPath = "\(outDir)/icon-preview.png" +try pngData(previewSheet()).write(to: URL(fileURLWithPath: previewPath)) + +print("iconset: \(iconset) (\(entries.count) png)") +print("preview: \(previewPath)") From e4ff1a1db276194e293a963f727af7730c4da65b Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Thu, 30 Jul 2026 14:43:58 +1200 Subject: [PATCH 20/23] docs: record manual acceptance + all changes since the initial Phase 4/5 batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User ran the app and confirmed all functionality works (2026-07-30), so the "待真机验证" items in backlog/impl notes are now marked verified — flagged explicitly as a user-reported result, not an automated assertion. Also records what landed after the first batch: two multi-agent review rounds and their fixes (ghost overlay, orphan SCStream, stale half-sentence translation, silent STT freeze), the translation trio (live volatile translation / failure fallback / skip in original-only), store O(1) + line cap, bar clipping + in-place updates, the gear-panel-then-back-to-menu-bar (.window style) decision, and the generated app icon. Plus a known-tradeoffs list and an unscheduled follow-up list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/superpowers/backlog.md | 20 +++++++---- .../2026-07-13-livesubtitle-phase4-5-impl.md | 33 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/backlog.md b/docs/superpowers/backlog.md index b5bff36..81a3723 100644 --- a/docs/superpowers/backlog.md +++ b/docs/superpowers/backlog.md @@ -7,7 +7,7 @@ - ✅ **字幕条(bar)可调**:菜单"布局编辑"开关 → bar 变可交互 + 可拖(关闭恢复点击穿透);位置存 `ls.barX/Y`;宽度用菜单滑条(`store.barWidth`,600–1400)。 - ✅ **小窗缩放**:mini panel 原生 `.resizable`,内容自适应,尺寸存 `ls.miniW/miniH`。 - ✅ **开 app 即请求权限**:`AppDelegate.applicationDidFinishLaunching` → `PermissionsManager.requestAllOnLaunch()`(麦克风 `AVCaptureDevice.requestAccess` + 屏幕录制 `CGRequestScreenCaptureAccess`)。**不是**挂菜单 `.task`(那要等点开菜单才触发)。 -- ⏳ **待真机验证**:bar 拖动/宽度、mini 边缘缩放、启动权限框弹出时机(borderless panel 边缘缩放的手感尤其需要肉眼确认)。 +- ✅ **真机验证通过**(2026-07-30 用户手测):bar 拖动/宽度、mini 边缘缩放、启动权限框弹出均正常。 - (原计划遗留,仍 deferred)回声/外放漏音兜底(**已决策:关 VoiceProcessing + 耳机**,见 probes/RESULTS.md)、音频路由变化处理、延迟调优 spike。 ## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 ✅ 代码已实现(2026-07-13) @@ -19,7 +19,7 @@ - ✅ **编排** `Export/ExportCoordinator.swift`:取终句 → 有 key 调 DeepSeek(失败/无 key 回退默认标题 + 占位 summary,**仍导出**)→ `Task.detached` 写盘。菜单按钮"整理并导出到 Obsidian"触发,状态显示在菜单。**手动触发**(尊重"主动导出才上云"的隐私边界)。 - ✅ **设置页** `Overlay/SettingsView.swift` + App `Settings` scene(菜单"设置…"用 `openSettings()` 打开):`SecureField` 配 DeepSeek key、`NSOpenPanel` 选 vault 目录。 - ⚠️ **API key 存 UserDefaults 明文**(自用可接受;未来上架/沙盒 → Keychain)。 -- ⏳ **待真机验证**:设置页打开、选目录、真实 DeepSeek 调用 + 写出 .md(需你填真 key + 选 vault)。 +- ✅ **真机验证通过**(2026-07-30 用户手测):设置页、选 vault 目录、导出流程均正常。 **已定决策(2026-07-09 与用户确认):** @@ -29,8 +29,16 @@ - **设置页**:至少含 DeepSeek API key、Obsidian vault 路径。 - 笔记内容形态待细化:frontmatter(title/date/tags/来源)+ 转录(对方/我)+ DeepSeek 总结段。导出触发时机(停止字幕后?手动按钮?)待 brainstorm。 -## 当前进行 / 状态(2026-07-13) +## 状态(2026-07-30) -- **Phase 3**(branch `phase3-overlay`,5 commits,18→19 测试绿):代码完成;**Task 6 真机手测仍未做**(你的活)。未合并 main。 -- **Phase 4 + 5**(branch `phase4-5-perms-bar-obsidian`,基于 phase3-overlay,6 commits,19 测试绿,bundle OK):代码完成、编译测试通过;**真机手测未做**。未合并 main。 -- **待你决定**:①Phase 3/4/5 真机验收顺序;②验收 OK 后如何合 main(整条 stack 合,还是逐 phase)。 +**Phase 3 + 4 + 5 全部完成并经用户真机验收**(功能正常),0 error / 31 XCTest 绿 / bundle 带图标。 +分支 `phase4-5-perms-bar-obsidian`(基于 `phase3-overlay`)共 19 commits,待合入 `main`。 +实现与后续变更明细见 `plans/2026-07-13-livesubtitle-phase4-5-impl.md`。 + +## 后续可做(未排期) + +- **翻译上下文**:本地逐句翻译无上下文,代词/专名前后不一致——要破得上云 LLM,与"实时链路全本地"冲突,暂不做。 +- **Keychain 存 API key**(替换 UserDefaults 明文),上架/沙盒化时必做。 +- **显示器热插拔时重定位已显示浮窗**(监听 `didChangeScreenParametersNotification`)。 +- **延迟调优 spike**:量化端到端延迟(spec §1 有 6 个指标),看边说边译开/关的实际差异。 +- 音频路由变化处理(切换耳机/外放中途)。 diff --git a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md index 9c43fcc..456eff5 100644 --- a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md +++ b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md @@ -27,13 +27,34 @@ - NSWindow move/resize 回调照抄既有 mini 的 `MainActor.assumeIsolated + [weak self]`。 - `DeepSeekClient` 纯值 struct + async,`Result` Sendable。 -## ⏳ 未验证(需真机手测,不能靠单测覆盖) +## 后续变更(2026-07-30,同一分支继续) -1. bar 布局编辑拖动 + 宽度滑条实时变化;bar 位置重启后恢复。 -2. mini 边缘缩放手感(borderless panel 边缘抓取区较窄,尤其需肉眼确认)+ 尺寸重启恢复。 -3. 启动时麦克风 + 屏幕录制权限框弹出时机。 -4. 设置页打开、`NSOpenPanel` 选 vault、填真 DeepSeek key → 真实调用 + 写出 .md 到 vault。 +| commit | 内容 | +|--------|------| +| `359b499` | 复审修复:幽灵浮窗(hide 后改设置会复活)、导出同名覆盖、导出重入、YAML title 转义、设置窗置前 | +| `cf247ce` | 翻译三项:**边说边译**(中间态也翻,降延迟,菜单可关)、失败回退显原文(不再永久「翻译中…」)、仅原文模式跳翻译 | +| `0dfd750` | 全量复审修复:**孤儿 SCStream**(停止后屏录仍开)、半句译文被当定稿译文、旧 consume 写脏新会话、识别流静默冻结 | +| `61c3c17` | store:id→index O(1) 查找 + 保留上限 2000 行(长会话内存/延迟蠕变) | +| `5bfdfe8` | 字幕条大字号不再被裁(底对齐)+ 属性变化就地更新(消除重建闪烁) | +| `9f1e86c` → `7c52a9a` | 外观控件位置:先试字幕条旁齿轮浮窗 → 用户判定不好看,**改回菜单栏并换 `.menuBarExtraStyle(.window)`**(原生 NSMenu 渲染不了滑块,会退化成 Decrement/Increment) | +| `97cc9dd` | 应用图标:`scripts/make-icon.swift` 纯 AppKit 生成 `.icns`(bars/cjk/mixed 三风格,默认 cjk)接入 bundle | + +**期间做了两轮多 agent 代码复审**(5 个 commit 逐一审 + 6 个子系统全量审),真 bug 都已修并有单测覆盖;被复审排除的疑点见各 commit message。 + +## ✅ 真机手测(2026-07-30,用户验收通过) + +用户实机跑过并确认「功能都正常」,覆盖 Phase 3/4/5 全部交互:显示三态、字幕条⇄小窗、Pin、透明度/字号/宽度、布局编辑拖动、小窗缩放、启动权限、设置页、导出。 + +> 记录口径:此结论来自用户的实机反馈,不是自动化测试断言。单测(31)只覆盖 store/模型/格式转换等纯逻辑层。 + +## 已知取舍(非缺陷) + +- **API key 明文存 UserDefaults** —— 自用取舍,上架/沙盒化再换 Keychain。 +- **保留行数上限 2000** —— 极长会话会丢最早的回看历史(导出用当前保留的行)。 +- **本地翻译逐句、无上下文、句界依赖 STT** —— Apple 本地 `TranslationSession` 的固有上限,要上下文得上云,与"实时链路不上云"的决策冲突,故不做。 +- **拔外接屏时已显示的浮窗不会自动重定位**(无 `didChangeScreenParametersNotification` 监听);下次创建时 `clampToScreen` 会夹回屏内。 +- **菜单栏图标仍是单色 SF Symbol**;`.icns` 只在 Finder/聚焦/简介可见(LSUIElement 无 Dock 图标)。 ## 未动 -音频 / 识别 / 翻译 / 管线(`Audio/**` `Speech/**` `Translation/**` `Pipeline/**`)一行没碰。 +音频 / 识别管线(`Audio/**` `Speech/**` `Pipeline/**` 的采集与识别部分)保持原状;本轮只在 `CaptionEngine` 编排层和 `TranslationService` 上按需改动(边说边译、串行化、错误上报)。 From 32b99965f67fb95f2ab320fab55927827ff50797 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Sat, 1 Aug 2026 22:51:07 +1200 Subject: [PATCH 21/23] =?UTF-8?q?fix:=20no=20menu=20bar=20icon=20=E2=80=94?= =?UTF-8?q?=20stop=20requesting=20both=20permissions=20at=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: launched from /Applications the app ran fine (event loop alive, no crash, no error) but had no menu bar icon at all. Root cause, bisected with minimal SwiftUI test apps (fresh bundle IDs to reproduce a first-launch TCC state): requesting BOTH microphone and screen-recording access during app launch prevents SwiftUI's MenuBarExtra status item from ever being installed. Requesting either one alone is fine; both together reproduces every time; deferring the call 2s after launch does NOT help. Silent — no exception, no log. Fix: request permissions at point of use (when the user hits 开始字幕) instead of at launch, which is also the idiomatic macOS pattern. Drops the AppDelegate adaptor that existed only for the launch-time call; PermissionsManager.requestAllOnLaunch is renamed to requestAll with the constraint documented at the call site so this isn't reintroduced. This walks back the Phase 4 "开 app 即请求权限" item: it is not implementable at launch without losing the menu bar icon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 14 ++++---------- .../LiveSubtitle/Overlay/PermissionsManager.swift | 12 ++++++++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 417b072..5d31b0e 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -11,10 +11,6 @@ struct LiveSubtitleApp: App { @State private var exportStatus = "" @State private var isExporting = false - // 启动即请求权限(麦克风 + 屏幕录制)——用 AppDelegate 的 applicationDidFinishLaunching, - // 而非菜单内容的 .task(后者要等用户点开菜单才触发)。 - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @Environment(\.openSettings) private var openSettings var body: some Scene { @@ -119,6 +115,10 @@ struct LiveSubtitleApp: App { if running { engine?.stop(); engine = nil; overlay.hide(); running = false; status = "" } else { + // 权限在此刻(真正要用时)请求,不在启动时。 + // 启动阶段同时拉起麦克风 + 屏幕录制两个 TCC 流程会导致 MenuBarExtra 的状态栏项建不出来 + // (实测:单独请求任一项都正常,两项同时请求则菜单栏无图标,延后请求也无效)。 + PermissionsManager.requestAll() let e = CaptionEngine(store: store) engine = e overlay.show(store: store) @@ -128,9 +128,3 @@ struct LiveSubtitleApp: App { } } -/// 启动即请求 麦克风 + 屏幕录制 授权(不等到点"开始字幕",也不等用户点开菜单)。 -final class AppDelegate: NSObject, NSApplicationDelegate { - func applicationDidFinishLaunching(_ notification: Notification) { - PermissionsManager.requestAllOnLaunch() - } -} diff --git a/Sources/LiveSubtitle/Overlay/PermissionsManager.swift b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift index 850d65e..eb2e0f8 100644 --- a/Sources/LiveSubtitle/Overlay/PermissionsManager.swift +++ b/Sources/LiveSubtitle/Overlay/PermissionsManager.swift @@ -2,12 +2,16 @@ import Foundation import AVFoundation import CoreGraphics -/// 前置权限申请:app 启动时一次性请求麦克风 + 屏幕录制授权。 -/// 屏幕录制授权是系统声音采集(ScreenCaptureKit)所需的前提。 +/// 权限申请:麦克风 + 屏幕录制(后者是 ScreenCaptureKit 采系统声音的前提)。 +/// +/// **必须在「开始字幕」时调用,不能在 app 启动阶段调用。** 实测(macOS 27): +/// 启动阶段同时拉起这两个 TCC 授权流程,会导致 SwiftUI `MenuBarExtra` 的状态栏项 +/// 根本建不出来——菜单栏上没有图标、也没有任何报错,app 却在正常运行。 +/// 单独请求任一项都正常;两项同时请求则必现;把请求延后 2 秒也无效。 @MainActor enum PermissionsManager { - /// 供 App 启动时调用。非阻塞:两项权限各自异步触发系统授权框,忽略结果。 - static func requestAllOnLaunch() { + /// 在真正要用权限时调用(点「开始字幕」)。非阻塞:两项各自异步触发系统授权框,忽略结果。 + static func requestAll() { // ① 麦克风:异步弹权限框,不关心结果。 AVCaptureDevice.requestAccess(for: .audio) { _ in } From 9db803f78c98b0482dbecb38cbfa0b07ae88d4d5 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Sat, 1 Aug 2026 22:57:18 +1200 Subject: [PATCH 22/23] feat: replace the menu bar extra with a regular window app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu bar status item never appeared when the app was launched from /Applications via LaunchServices — AX reported the item as registered, the app ran fine with a live event loop, no crash and no log output, but nothing was drawn. Bisecting with minimal SwiftUI apps did not produce a reliable fix, so drop the menu-bar-only design instead of fighting it. - MenuBarExtra(.window) → WindowGroup with the same control panel (300pt wide, .windowResizability(.contentSize)). - Info.plist no longer sets LSUIElement, so this is a normal foreground app: it has a Dock icon (the generated .icns is finally visible) and a standard app menu. - Dropped the panel's 退出 button (⌘Q / app menu covers it) and the NSApp.activate dance before openSettings, which only existed for the menu bar case. - Subtitle overlays (bar/mini NSPanels) are unchanged and still float independently. Verified on the installed copy: launched via LaunchServices, background-only=false, window "LiveSubtitle" 300x490 present, app shows in the Dock. 0 errors, 31 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/LiveSubtitle/LiveSubtitleApp.swift | 25 ++++++++-------------- scripts/build-app.sh | 1 - 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Sources/LiveSubtitle/LiveSubtitleApp.swift b/Sources/LiveSubtitle/LiveSubtitleApp.swift index 5d31b0e..fc7eefb 100644 --- a/Sources/LiveSubtitle/LiveSubtitleApp.swift +++ b/Sources/LiveSubtitle/LiveSubtitleApp.swift @@ -14,19 +14,19 @@ struct LiveSubtitleApp: App { @Environment(\.openSettings) private var openSettings var body: some Scene { - // .window 样式:下拉是真正的 SwiftUI 面板,滑块能正常渲染并拖动 - // (默认 .menu 走原生 NSMenu,Slider 会退化成 Decrement/Increment 子菜单) - MenuBarExtra("LiveSubtitle", systemImage: "captions.bubble") { - menuPanel + // 普通窗口 app(不再是菜单栏 app):控制面板是一个正常窗口,Dock 里有图标。 + // 字幕本身仍是独立浮窗(OverlayController 的 bar/mini),与本窗口无关。 + WindowGroup("LiveSubtitle") { + controlPanel } - .menuBarExtraStyle(.window) + .windowResizability(.contentSize) Settings { SettingsView(store: store) } } - @ViewBuilder private var menuPanel: some View { + @ViewBuilder private var controlPanel: some View { @Bindable var s = store VStack(alignment: .leading, spacing: 12) { Button(running ? "停止字幕" : "开始字幕") { toggle() } @@ -69,17 +69,10 @@ struct LiveSubtitleApp: App { if !exportStatus.isEmpty { Text(exportStatus).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } - HStack { - Button("设置…") { - NSApp.activate(ignoringOtherApps: true) // 菜单栏触发时确保设置窗口置前获焦 - openSettings() - } - Spacer() - Button("退出") { NSApplication.shared.terminate(nil) } - } + Button("设置…") { openSettings() } // 退出走 app 菜单 / ⌘Q } - .padding(14) - .frame(width: 280) + .padding(16) + .frame(width: 300) } /// 带右侧数值的紧凑滑块行(.window 样式下 Slider 正常可用)。 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b702b61..4437e3d 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -29,7 +29,6 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' <key>CFBundlePackageType</key><string>APPL</string> <key>CFBundleShortVersionString</key><string>0.1</string> <key>LSMinimumSystemVersion</key><string>26.0</string> - <key>LSUIElement</key><true/> <key>NSScreenCaptureUsageDescription</key><string>LiveSubtitle 采集系统音频用于实时中文字幕(仅音频)。</string> <key>NSMicrophoneUsageDescription</key><string>LiveSubtitle 采集麦克风用于识别你的发言。</string> </dict> From 14ae6347d4c9e7239559ad793b0fcf0b9eaddf15 Mon Sep 17 00:00:00 2001 From: zhangchaolong <zcl0621@hotmail.com> Date: Sat, 1 Aug 2026 22:59:12 +1200 Subject: [PATCH 23/23] =?UTF-8?q?docs:=20record=20the=20menu-bar=20?= =?UTF-8?q?=E2=86=92=20window=20switch=20and=20the=20unsolved=20status-ite?= =?UTF-8?q?m=20mystery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4's "开 app 即请求权限" is marked withdrawn (permissions now requested when captioning starts), and the menu-bar-driven design from the PRD/Phase 3 is recorded as replaced by a regular WindowGroup app. Keeps an honest account of the status-item investigation: what was ruled out, which diagnostic tools gave misleading output (lsappinfo misuse, AX titles on accessory apps), and that the "both permission requests at launch" root cause was later disproved — the real cause is still unknown, the window app works around it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/superpowers/backlog.md | 17 ++++++++++++++--- .../2026-07-13-livesubtitle-phase4-5-impl.md | 10 ++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/backlog.md b/docs/superpowers/backlog.md index 81a3723..ca92e7a 100644 --- a/docs/superpowers/backlog.md +++ b/docs/superpowers/backlog.md @@ -4,10 +4,10 @@ ## Phase 4 — 字幕条可操作 + 权限前置 ✅ 代码已实现(2026-07-13,branch phase4-5-perms-bar-obsidian) -- ✅ **字幕条(bar)可调**:菜单"布局编辑"开关 → bar 变可交互 + 可拖(关闭恢复点击穿透);位置存 `ls.barX/Y`;宽度用菜单滑条(`store.barWidth`,600–1400)。 +- ✅ **字幕条(bar)可调**:"布局编辑"开关 → bar 变可交互 + 可拖(关闭恢复点击穿透);位置存 `ls.barX/Y`;宽度用滑块(`store.barWidth`,600–1400)。 - ✅ **小窗缩放**:mini panel 原生 `.resizable`,内容自适应,尺寸存 `ls.miniW/miniH`。 -- ✅ **开 app 即请求权限**:`AppDelegate.applicationDidFinishLaunching` → `PermissionsManager.requestAllOnLaunch()`(麦克风 `AVCaptureDevice.requestAccess` + 屏幕录制 `CGRequestScreenCaptureAccess`)。**不是**挂菜单 `.task`(那要等点开菜单才触发)。 -- ✅ **真机验证通过**(2026-07-30 用户手测):bar 拖动/宽度、mini 边缘缩放、启动权限框弹出均正常。 +- ❌ **「开 app 即请求权限」已撤销**(2026-07-30):改为**点「开始字幕」时**才调 `PermissionsManager.requestAll()`。启动阶段拉起 TCC 授权流程与菜单栏图标问题纠缠不清,且点用时请求本就是 macOS 惯例。 +- ✅ **真机验证通过**(2026-07-30 用户手测):bar 拖动/宽度、mini 缩放、控制面板、导出、设置页均正常。 - (原计划遗留,仍 deferred)回声/外放漏音兜底(**已决策:关 VoiceProcessing + 耳机**,见 probes/RESULTS.md)、音频路由变化处理、延迟调优 spike。 ## Phase 5(新子系统)— Obsidian 导出 + DeepSeek 总结 + 设置页 ✅ 代码已实现(2026-07-13) @@ -29,6 +29,17 @@ - **设置页**:至少含 DeepSeek API key、Obsidian vault 路径。 - 笔记内容形态待细化:frontmatter(title/date/tags/来源)+ 转录(对方/我)+ DeepSeek 总结段。导出触发时机(停止字幕后?手动按钮?)待 brainstorm。 +## ⚠️ 形态变更:菜单栏 app → 普通窗口 app(2026-07-30) + +PRD/Phase 3 原定"菜单栏驱动"(`MenuBarExtra`),**已改为普通窗口 app**。原因: + +- 从 `/Applications` 经访达/LaunchServices 启动时,**菜单栏图标始终不出现**:AX 显示状态项已注册、app 事件循环正常、无崩溃、无任何日志输出,但就是不绘制;从 shell 直接起有时可见,从访达起不可见。 +- 用最小 SwiftUI 测试 app 做过多轮二分(符号有效性、`.menu`/`.window` 样式、`Settings` 场景、`@NSApplicationDelegateAdaptor`、两项权限请求的组合、全新 bundle id 复现首次启动)。一度以为是"启动时同时请求麦克风+屏幕录制"所致,**该结论后被证伪**(去掉权限请求仍不显示)。 +- **根因未查明**,故不再纠缠:改为 `WindowGroup` 控制面板窗口 + 去掉 `LSUIElement`。附带好处是有了 Dock 图标(`.icns` 派上用场)和标准 app 菜单(⌘Q/⌘,)。 +- 字幕浮窗(bar/mini `NSPanel`)不受影响,仍是独立浮层。 + +> 若以后想回菜单栏形态:`MenuBarExtra` 相关代码见 commit `9db803f` 之前的版本;需先解决上述"状态项注册但不绘制"的问题。 + ## 状态(2026-07-30) **Phase 3 + 4 + 5 全部完成并经用户真机验收**(功能正常),0 error / 31 XCTest 绿 / bundle 带图标。 diff --git a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md index 456eff5..e924535 100644 --- a/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md +++ b/docs/superpowers/plans/2026-07-13-livesubtitle-phase4-5-impl.md @@ -38,6 +38,16 @@ | `5bfdfe8` | 字幕条大字号不再被裁(底对齐)+ 属性变化就地更新(消除重建闪烁) | | `9f1e86c` → `7c52a9a` | 外观控件位置:先试字幕条旁齿轮浮窗 → 用户判定不好看,**改回菜单栏并换 `.menuBarExtraStyle(.window)`**(原生 NSMenu 渲染不了滑块,会退化成 Decrement/Increment) | | `97cc9dd` | 应用图标:`scripts/make-icon.swift` 纯 AppKit 生成 `.icns`(bars/cjk/mixed 三风格,默认 cjk)接入 bundle | +| `32b9996` | 权限请求从启动时移到「开始字幕」时(见下方"菜单栏图标"一节;该 commit 的根因判断后被证伪,但**改动本身保留**——点用时请求是更合理的 macOS 惯例) | +| `9db803f` | **形态变更:菜单栏 app → 普通窗口 app**(`WindowGroup` + 去掉 `LSUIElement`) | + +## 菜单栏图标失踪(未解之谜 → 绕开) + +装到 `/Applications` 经访达启动后,菜单栏图标始终不出现,且**完全静默**:AX 显示状态项已注册、`NSApplicationMain` 事件循环正常、无崩溃、无 stderr、无系统日志。 + +排查手段(留档,免得以后重走):`sample` 抓主线程栈确认没卡死;`lsappinfo`(**参数用错过一次,输出不可信**);System Events 读菜单栏结构(**accessory app 的 AX 标题是假象,最小对照 app 同样乱**);`screencapture`(无屏录权限,拿不到图);最后靠**最小 SwiftUI 测试 app + 用户肉眼**做二分:符号有效性 ✓、`.menu`/`.window` 样式 ✓、`Settings` 场景 ✓、`@NSApplicationDelegateAdaptor` ✓、全新 bundle id 复现首次启动。 + +一度定位到"启动时同时请求麦克风 + 屏幕录制",但**该结论被后续验证证伪**(去掉权限请求后,访达启动的实例仍无图标)。**根因至今未查明**,遂改用普通窗口形态绕开。 **期间做了两轮多 agent 代码复审**(5 个 commit 逐一审 + 6 个子系统全量审),真 bug 都已修并有单测覆盖;被复审排除的疑点见各 commit message。