127 lines
5.6 KiB
Swift
127 lines
5.6 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>()
|
|
|
|
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 busy = state == .starting || state == .finalizing
|
|
let symbol = busy ? "arrow.trianglehead.2.clockwise.rotate.90" : (state == .recording ? "waveform" : "mic")
|
|
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: stateItem.title)
|
|
image?.isTemplate = true
|
|
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 = busy ? CATransform3DMakeRotation(CGFloat(frame) * .pi / 8, 0, 0, 1) : 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) }
|
|
}
|