Files
tinystt/Sources/TinySTT/UI/HUDController.swift
T

208 lines
7.0 KiB
Swift

import AppKit
import SwiftUI
@MainActor
final class HUDModel: ObservableObject {
enum Phase {
case preparing
case listening
case processing
}
@Published var text: String = ""
@Published var level: Float = 0
@Published var phase: Phase = .listening
@Published var hint: String = "再按一次结束 · Esc 取消"
}
struct HUDView: View {
@ObservedObject var model: HUDModel
var body: some View {
HStack(alignment: .center, spacing: 14) {
Waveform(level: model.level, active: model.phase == .listening)
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
if model.phase != .listening {
ProgressView()
.controlSize(.small)
.scaleEffect(0.7)
}
Text(displayText)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(model.text.isEmpty ? Color.secondary : Color.primary)
.lineLimit(3)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Text(model.phase == .preparing ? "等待本地引擎就绪后再说话 · Esc 取消" : (model.phase == .processing ? "正在整理文字…" : model.hint))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(width: 480, height: 104, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.regularMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.primary.opacity(0.09), lineWidth: 0.5)
)
}
private var displayText: String {
if model.text.isEmpty {
return model.phase == .preparing ? "正在准备本地识别…" : (model.phase == .processing ? " " : "聆听中…")
}
return model.text
}
}
private struct Waveform: View {
var level: Float
var active: Bool
var body: some View {
HStack(alignment: .center, spacing: 3) {
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(active ? Color.accentColor : Color.secondary.opacity(0.4))
.frame(width: 3, height: height(for: index))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeOut(duration: 0.12), value: level)
}
private func height(for index: Int) -> CGFloat {
let weights: [CGFloat] = [0.45, 0.75, 1.0, 0.7, 0.5]
let base: CGFloat = 5
let maxHeight: CGFloat = 30
let l = CGFloat(max(0, min(1, level)))
return base + (maxHeight - base) * l * weights[index]
}
}
@MainActor
final class HUDController {
private var panel: NSPanel?
private var tracking: Timer?
private let model = HUDModel()
var isVisible: Bool { panel?.isVisible ?? false }
func show() {
model.text = ""
model.level = 0
model.phase = .preparing
model.hint = "\(Prefs.shared.languageCode) · " + (Prefs.shared.triggerMode == .hold ? "松开结束 · Esc 取消" : "再按一次结束 · Esc 取消")
let panel = ensurePanel()
position(panel)
panel.alphaValue = 0
panel.orderFrontRegardless()
tracking?.invalidate()
tracking = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
guard let self, let panel = self.panel else { return }
self.position(panel)
}
}
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
panel.animator().alphaValue = 1
}
}
func update(text: String) {
model.text = text
}
func update(level: Float) {
// 平滑一下,避免抖动
model.level = model.level * 0.6 + level * 0.4
}
func setListening() { model.phase = .listening }
func setProcessing() {
model.phase = .processing
}
func hide() {
tracking?.invalidate()
tracking = nil
guard let panel, panel.isVisible else { return }
panel.orderOut(nil)
}
private func ensurePanel() -> NSPanel {
if let panel { return panel }
let panel = CaptionPanel(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 104),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isFloatingPanel = true
panel.level = .statusBar
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.ignoresMouseEvents = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
panel.contentView = NSHostingView(rootView: HUDView(model: model))
self.panel = panel
return panel
}
private func position(_ panel: NSPanel) {
let size = panel.frame.size
let screen = screenUnderMouse() ?? NSScreen.main
guard let screen else { return }
var origin: CGPoint
if !Prefs.shared.hudAtBottom, let ax = Inserter.focusedFrame() {
// AX 坐标系:主屏左上角为原点,y 向下
let primaryHeight = NSScreen.screens
.first { $0.frame.origin == .zero }?.frame.height ?? screen.frame.height
let appKitY = primaryHeight - ax.maxY
var x = ax.minX + ax.width / 2 - size.width / 2
var y = appKitY - size.height - 10
// 下方空间不够时显示到输入框上方
if y < screen.visibleFrame.minY + 8 {
y = primaryHeight - ax.minY + 10
}
x = min(max(x, screen.visibleFrame.minX + 8), screen.visibleFrame.maxX - size.width - 8)
y = min(max(y, screen.visibleFrame.minY + 8), screen.visibleFrame.maxY - size.height - 8)
origin = CGPoint(x: x, y: y)
} else {
origin = CGPoint(x: screen.frame.midX - size.width / 2,
y: screen.visibleFrame.minY + 120)
}
panel.setFrame(NSRect(origin: origin, size: size), display: true)
}
private func screenUnderMouse() -> NSScreen? {
let location = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(location) }
}
}
private final class CaptionPanel: NSPanel {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}