151 lines
6.5 KiB
Swift
151 lines
6.5 KiB
Swift
import AppKit
|
|
import Combine
|
|
|
|
@MainActor
|
|
final class StatusItemController: NSObject, NSMenuDelegate {
|
|
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
private let menu = NSMenu()
|
|
private let stateItem = NSMenuItem()
|
|
private let modelItem = NSMenuItem()
|
|
private let engineItem = NSMenuItem()
|
|
private let permissionItem = NSMenuItem()
|
|
private let toggleItem = NSMenuItem()
|
|
private let hintItem = NSMenuItem()
|
|
private let cancelItem = NSMenuItem(title: "取消此次听写", action: nil, keyEquivalent: "")
|
|
private var state: DictationController.State = .idle
|
|
private var animation: Timer?
|
|
private var frame = 0
|
|
private var subscriptions = Set<AnyCancellable>()
|
|
private let brandImage = Bundle.main.url(forResource: "AppIcon", withExtension: "icns")
|
|
.flatMap { NSImage(contentsOf: $0) } ?? NSApp.applicationIconImage!
|
|
|
|
var onToggle: (() -> Void)?
|
|
var onCancel: (() -> Void)?
|
|
var onSettings: (() -> Void)?
|
|
|
|
override init() {
|
|
super.init()
|
|
menu.delegate = self
|
|
menu.autoenablesItems = false
|
|
for item in [stateItem, modelItem, engineItem, permissionItem] { item.isEnabled = false; menu.addItem(item) }
|
|
menu.addItem(.separator())
|
|
toggleItem.target = self
|
|
toggleItem.action = #selector(toggleTapped)
|
|
menu.addItem(toggleItem)
|
|
cancelItem.target = self
|
|
cancelItem.action = #selector(cancelTapped)
|
|
menu.addItem(cancelItem)
|
|
hintItem.isEnabled = false
|
|
menu.addItem(hintItem)
|
|
menu.addItem(.separator())
|
|
for (title, code, action) in [
|
|
("设置与测试语音…", ",", #selector(settingsTapped)),
|
|
("下载 / 检查语音模型", "", #selector(downloadTapped)),
|
|
("复制上次识别结果", "", #selector(copyTapped)),
|
|
("退出 TinySTT", "q", #selector(quitTapped))
|
|
] {
|
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: code)
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
statusItem.menu = menu
|
|
ModelManager.shared.$state.sink { [weak self] _ in
|
|
DispatchQueue.main.async { self?.refresh() }
|
|
}.store(in: &subscriptions)
|
|
Prefs.shared.objectWillChange.sink { [weak self] _ in
|
|
DispatchQueue.main.async { self?.refresh() }
|
|
}.store(in: &subscriptions)
|
|
NotificationCenter.default.publisher(for: .init("tinystt.engineStatus")).sink { [weak self] _ in
|
|
self?.refresh()
|
|
}.store(in: &subscriptions)
|
|
update(state: .idle)
|
|
}
|
|
|
|
func menuWillOpen(_ menu: NSMenu) { refresh() }
|
|
|
|
func update(state: DictationController.State) {
|
|
self.state = state
|
|
animation?.invalidate()
|
|
animation = nil
|
|
if state.isBusy {
|
|
animation = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated {
|
|
guard let self else { return }
|
|
self.frame += 1
|
|
self.drawIcon()
|
|
}
|
|
}
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
private func refresh() {
|
|
let prefs = Prefs.shared
|
|
switch state {
|
|
case .idle: stateItem.title = "TinySTT · 待机"
|
|
case .starting: stateItem.title = "TinySTT · 正在准备…"
|
|
case .recording: stateItem.title = "TinySTT · 正在聆听"
|
|
case .finalizing: stateItem.title = "TinySTT · 正在整理文字…"
|
|
}
|
|
modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)"
|
|
engineItem.title = SpeechEngine.statusDescription
|
|
permissionItem.title = "麦克风\(Permissions.microphone == .granted ? " ✓" : " 未授权") · 辅助功能\(Permissions.accessibility ? " ✓" : " 未授权")"
|
|
toggleItem.title = state == .recording ? "结束并写入" : "开始听写"
|
|
toggleItem.isEnabled = state == .idle || state == .recording
|
|
cancelItem.isEnabled = state.isBusy
|
|
hintItem.title = "\(prefs.hotkey.label) · " + (prefs.triggerMode == .hold ? "按住说话 · Esc 取消" : "按键切换 · Esc 取消")
|
|
drawIcon()
|
|
}
|
|
|
|
private func drawIcon() {
|
|
guard let button = statusItem.button else { return }
|
|
let icon = brandImage
|
|
let state = self.state
|
|
let frame = self.frame
|
|
let image = NSImage(size: NSSize(width: 22, height: 18), flipped: false) { _ in
|
|
icon.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18))
|
|
if state != .idle {
|
|
let badge = NSRect(x: 15, y: 0, width: 7, height: 7)
|
|
NSColor.windowBackgroundColor.setFill()
|
|
NSBezierPath(ovalIn: badge).fill()
|
|
if state == .recording {
|
|
NSColor.systemRed.setFill()
|
|
NSBezierPath(ovalIn: badge.insetBy(dx: 1, dy: 1)).fill()
|
|
} else {
|
|
let arc = NSBezierPath()
|
|
let angle = CGFloat(frame * 30)
|
|
arc.appendArc(withCenter: NSPoint(x: badge.midX, y: badge.midY),
|
|
radius: 2.5, startAngle: angle, endAngle: angle + 250)
|
|
arc.lineWidth = 1.5
|
|
arc.lineCapStyle = .round
|
|
NSColor.controlAccentColor.setStroke()
|
|
arc.stroke()
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
image.isTemplate = false
|
|
button.image = image
|
|
button.imagePosition = .imageLeading
|
|
button.title = Prefs.shared.compactIcon ? "" : " TinySTT"
|
|
button.contentTintColor = state == .recording ? .systemRed : nil
|
|
button.alphaValue = state == .recording ? 0.65 + 0.35 * sin(Double(frame) * 0.5) : 1
|
|
button.wantsLayer = true
|
|
button.layer?.sublayerTransform = CATransform3DIdentity
|
|
button.toolTip = "\(stateItem.title)\n\(modelItem.title)\n\(hintItem.title)"
|
|
button.setAccessibilityLabel(button.toolTip)
|
|
}
|
|
|
|
@objc private func copyTapped() {
|
|
let text = DictationController.shared.lastResult
|
|
guard !text.isEmpty else { return }
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
}
|
|
@objc private func toggleTapped() { onToggle?() }
|
|
@objc private func cancelTapped() { onCancel?() }
|
|
@objc private func settingsTapped() { onSettings?() }
|
|
@objc private func downloadTapped() { Task { await ModelManager.shared.refresh(); await ModelManager.shared.download() } }
|
|
@objc private func quitTapped() { NSApp.terminate(nil) }
|
|
}
|