- CGEvent cghidEventTap 全局监听 Fn 键 - Apple Speech 流式语音识别(zh-CN 默认) - 实时音频 RMS 电平驱动波形动画 - NSPanel 胶囊浮动窗口 - 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换 - OpenAI 兼容 LLM 纠错(DeepSeek 预设) - LSUIElement 菜单栏运行 - SPM + Makefile 构建
255 lines
8.9 KiB
Swift
255 lines
8.9 KiB
Swift
import Cocoa
|
||
|
||
/// 录音期间屏幕底部居中的胶囊形浮动窗口
|
||
final class RecordingPanel: NSPanel {
|
||
|
||
// MARK: - 子视图
|
||
let waveformView: WaveformView
|
||
private let textLabel: NSTextField
|
||
private let containerStack: NSStackView
|
||
|
||
// 文本宽度约束
|
||
private var textWidthConstraint: NSLayoutConstraint?
|
||
|
||
// 窗口宽度常量
|
||
private let minPanelWidth: CGFloat = 180
|
||
private let maxPanelWidth: CGFloat = 680
|
||
private let panelHeight: CGFloat = 56
|
||
private let cornerRadius: CGFloat = 28
|
||
private let waveformSize = NSSize(width: 44, height: 32)
|
||
private let marginAboveDock: CGFloat = 12
|
||
|
||
init() {
|
||
// Waveform
|
||
waveformView = WaveformView(frame: NSRect(origin: .zero, size: waveformSize))
|
||
waveformView.translatesAutoresizingMaskIntoConstraints = false
|
||
waveformView.wantsLayer = true
|
||
|
||
// Label
|
||
textLabel = NSTextField(labelWithString: "")
|
||
textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
textLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||
textLabel.textColor = .white
|
||
textLabel.alignment = .left
|
||
textLabel.lineBreakMode = .byTruncatingTail
|
||
textLabel.maximumNumberOfLines = 1
|
||
textLabel.backgroundColor = .clear
|
||
textLabel.isBezeled = false
|
||
textLabel.drawsBackground = false
|
||
|
||
// Stack
|
||
containerStack = NSStackView(views: [waveformView, textLabel])
|
||
containerStack.translatesAutoresizingMaskIntoConstraints = false
|
||
containerStack.orientation = .horizontal
|
||
containerStack.alignment = .centerY
|
||
containerStack.spacing = 12
|
||
containerStack.edgeInsets = NSEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||
|
||
// Panel 配置
|
||
super.init(
|
||
contentRect: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight),
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
|
||
configurePanel()
|
||
configureVisualEffect()
|
||
setupConstraints()
|
||
}
|
||
|
||
private func configurePanel() {
|
||
isFloatingPanel = true
|
||
becomesKeyOnlyIfNeeded = true
|
||
isOpaque = false
|
||
hasShadow = false
|
||
level = .floating
|
||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||
ignoresMouseEvents = false
|
||
titleVisibility = .hidden
|
||
titlebarAppearsTransparent = true
|
||
isMovableByWindowBackground = false
|
||
|
||
// 彻底去除边框
|
||
backgroundColor = .clear
|
||
isOpaque = false
|
||
}
|
||
|
||
private func configureVisualEffect() {
|
||
let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight))
|
||
visualEffect.material = .hudWindow
|
||
visualEffect.blendingMode = .behindWindow
|
||
visualEffect.state = .active
|
||
visualEffect.wantsLayer = true
|
||
visualEffect.layer?.cornerRadius = cornerRadius
|
||
visualEffect.layer?.masksToBounds = true
|
||
visualEffect.layer?.borderWidth = 0
|
||
visualEffect.layer?.borderColor = NSColor.clear.cgColor
|
||
visualEffect.autoresizingMask = [.width, .height]
|
||
|
||
// 替换 contentView
|
||
contentView = visualEffect
|
||
visualEffect.addSubview(containerStack)
|
||
}
|
||
|
||
private func setupConstraints() {
|
||
guard let content = contentView else { return }
|
||
|
||
NSLayoutConstraint.activate([
|
||
// Waveform 固定尺寸
|
||
waveformView.widthAnchor.constraint(equalToConstant: waveformSize.width),
|
||
waveformView.heightAnchor.constraint(equalToConstant: waveformSize.height),
|
||
|
||
// Container 填充
|
||
containerStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||
containerStack.centerYAnchor.constraint(equalTo: content.centerYAnchor),
|
||
containerStack.heightAnchor.constraint(equalToConstant: panelHeight)
|
||
])
|
||
|
||
// 文本弹性宽度
|
||
textWidthConstraint = textLabel.widthAnchor.constraint(equalToConstant: 160)
|
||
textWidthConstraint?.isActive = true
|
||
}
|
||
|
||
/// 更新转录文本
|
||
func updateText(_ text: String) {
|
||
textLabel.stringValue = text
|
||
|
||
// 计算所需宽度
|
||
let maxTextWidth = maxPanelWidth - waveformSize.width - 40 // 40 = spacing + insets
|
||
let attributes: [NSAttributedString.Key: Any] = [.font: textLabel.font!]
|
||
let textSize = (text as NSString).size(withAttributes: attributes)
|
||
let requiredTextWidth = min(max(textSize.width + 10, 160), maxTextWidth)
|
||
let targetPanelWidth = requiredTextWidth + waveformSize.width + 44
|
||
|
||
// 动画更新宽度
|
||
NSAnimationContext.runAnimationGroup { ctx in
|
||
ctx.duration = 0.25
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
ctx.allowsImplicitAnimation = true
|
||
|
||
textWidthConstraint?.animator().constant = requiredTextWidth
|
||
|
||
let frame = currentScreenFrame()
|
||
let y = bottomY(for: frame)
|
||
let newFrame = NSRect(
|
||
x: (frame.width - targetPanelWidth) / 2,
|
||
y: y,
|
||
width: targetPanelWidth,
|
||
height: panelHeight
|
||
)
|
||
animator().setFrame(newFrame, display: true)
|
||
}
|
||
}
|
||
|
||
/// 显示面板(弹簧动画)
|
||
func showAnimated() {
|
||
let frame = currentScreenFrame()
|
||
let y = bottomY(for: frame)
|
||
|
||
let initialFrame = NSRect(
|
||
x: (frame.width - minPanelWidth) / 2,
|
||
y: y,
|
||
width: minPanelWidth,
|
||
height: panelHeight
|
||
)
|
||
|
||
// 初始状态:缩小且透明
|
||
setFrame(initialFrame, display: false)
|
||
alphaValue = 0
|
||
|
||
// 出场
|
||
makeKeyAndOrderFront(nil)
|
||
|
||
// 弹簧动画
|
||
NSAnimationContext.runAnimationGroup { ctx in
|
||
ctx.duration = 0.35
|
||
ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.0, 0.5, 0.3, 1.5) // 弹簧曲线
|
||
animator().alphaValue = 1.0
|
||
}
|
||
// 弹簧效果通过 CATransaction 模拟
|
||
let spring = CASpringAnimation(keyPath: "transform.scale")
|
||
spring.fromValue = 0.85
|
||
spring.toValue = 1.0
|
||
spring.duration = 0.35
|
||
spring.damping = 12
|
||
spring.initialVelocity = 0
|
||
spring.fillMode = .backwards
|
||
contentView?.layer?.add(spring, forKey: "showSpring")
|
||
}
|
||
|
||
/// 隐藏面板(缩放动画)
|
||
func hideAnimated(completion: (() -> Void)? = nil) {
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.22
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||
animator().alphaValue = 0
|
||
|
||
// 缩放
|
||
let scale = CABasicAnimation(keyPath: "transform.scale")
|
||
scale.fromValue = 1.0
|
||
scale.toValue = 0.9
|
||
scale.duration = 0.22
|
||
scale.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||
scale.fillMode = .forwards
|
||
scale.isRemovedOnCompletion = false
|
||
contentView?.layer?.add(scale, forKey: "hideScale")
|
||
|
||
}, completionHandler: {
|
||
self.orderOut(nil)
|
||
self.contentView?.layer?.removeAnimation(forKey: "hideScale")
|
||
self.textLabel.stringValue = ""
|
||
self.textWidthConstraint?.animator().constant = 160
|
||
completion?()
|
||
})
|
||
}
|
||
|
||
/// 显示 Refining 状态(弹窗自适应缩窄)
|
||
func showRefining() {
|
||
textLabel.stringValue = "Refining..."
|
||
let refiningTextWidth: CGFloat = 120
|
||
let targetWidth: CGFloat = refiningTextWidth + waveformSize.width + 44
|
||
|
||
NSAnimationContext.runAnimationGroup { ctx in
|
||
ctx.duration = 0.2
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
ctx.allowsImplicitAnimation = true
|
||
|
||
textWidthConstraint?.animator().constant = refiningTextWidth
|
||
|
||
let frame = currentScreenFrame()
|
||
let y = bottomY(for: frame)
|
||
let newFrame = NSRect(
|
||
x: (frame.width - targetWidth) / 2,
|
||
y: y,
|
||
width: targetWidth,
|
||
height: panelHeight
|
||
)
|
||
animator().setFrame(newFrame, display: true)
|
||
}
|
||
|
||
waveformView.updateLevel(0.3)
|
||
}
|
||
|
||
// 重写:允许在非 active 状态响应
|
||
override var canBecomeKey: Bool { true }
|
||
override var canBecomeMain: Bool { false }
|
||
|
||
// MARK: - 屏幕坐标
|
||
|
||
private func currentScreenFrame() -> NSRect {
|
||
// 优先使用鼠标所在屏幕,否则主屏幕
|
||
let mouseLocation = NSEvent.mouseLocation
|
||
for screen in NSScreen.screens {
|
||
if screen.frame.contains(mouseLocation) {
|
||
return screen.visibleFrame
|
||
}
|
||
}
|
||
return NSScreen.main?.visibleFrame ?? .zero
|
||
}
|
||
|
||
private func bottomY(for screenFrame: NSRect) -> CGFloat {
|
||
screenFrame.minY + marginAboveDock
|
||
}
|
||
}
|