360 lines
13 KiB
Swift
360 lines
13 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@State private var tab: Tab = .general
|
|
|
|
enum Tab: Hashable {
|
|
case general, hotkey, speech, about
|
|
}
|
|
|
|
var body: some View {
|
|
TabView(selection: $tab) {
|
|
GeneralPane()
|
|
.tabItem { Label("通用", systemImage: "gearshape") }
|
|
.tag(Tab.general)
|
|
HotkeyPane()
|
|
.tabItem { Label("快捷键", systemImage: "command") }
|
|
.tag(Tab.hotkey)
|
|
SpeechPane()
|
|
.tabItem { Label("语音", systemImage: "waveform") }
|
|
.tag(Tab.speech)
|
|
AboutPane()
|
|
.tabItem { Label("关于", systemImage: "info.circle") }
|
|
.tag(Tab.about)
|
|
}
|
|
.frame(width: 540, height: 430)
|
|
.onReceive(NotificationCenter.default.publisher(for: .tinytssOpenSettings)) { _ in
|
|
// 保持当前 tab
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 通用
|
|
|
|
struct GeneralPane: View {
|
|
@ObservedObject var prefs = Prefs.shared
|
|
@State private var launchAtLogin = LaunchAtLogin.isEnabled
|
|
@State private var launchMessage: String?
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
Toggle("开机自动启动", isOn: $launchAtLogin)
|
|
.onChange(of: launchAtLogin) { _, newValue in
|
|
if let error = LaunchAtLogin.set(newValue) {
|
|
launchMessage = error
|
|
launchAtLogin = LaunchAtLogin.isEnabled
|
|
} else {
|
|
launchMessage = nil
|
|
}
|
|
}
|
|
if let launchMessage {
|
|
Text(launchMessage)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
} header: {
|
|
Text("启动")
|
|
}
|
|
|
|
Section {
|
|
Toggle("菜单栏只显示图标", isOn: $prefs.compactIcon)
|
|
Toggle("字幕固定在屏幕底部", isOn: $prefs.hudAtBottom)
|
|
Toggle("12 秒无声自动结束", isOn: $prefs.silenceTimeout)
|
|
Toggle("显示浮动字幕", isOn: $prefs.showHUD)
|
|
Toggle("开始和完成时播放提示音", isOn: $prefs.playSound)
|
|
} header: {
|
|
Text("界面")
|
|
}
|
|
|
|
Section {
|
|
Picker("文字写入方式", selection: $prefs.insertMethod) {
|
|
ForEach(InsertMethod.allCases) { method in
|
|
Text(method.title).tag(method)
|
|
}
|
|
}
|
|
Text("「自动」会先尝试直接写入光标位置;不支持的应用(如部分网页输入框)自动改用粘贴。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("写入")
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
}
|
|
|
|
// MARK: - 快捷键
|
|
|
|
struct HotkeyPane: View {
|
|
@ObservedObject var prefs = Prefs.shared
|
|
@StateObject private var recorder = HotkeyRecorderModel()
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
HStack {
|
|
Text("当前快捷键")
|
|
Spacer()
|
|
Text(recorder.isRecording ? "请按下新的快捷键…" : prefs.hotkey.label)
|
|
.font(.system(.body, design: .rounded))
|
|
.foregroundStyle(recorder.isRecording ? Color.accentColor : Color.primary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.primary.opacity(0.06))
|
|
)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(recorder.isRecording ? "取消录制" : "重新录制") {
|
|
if recorder.isRecording {
|
|
recorder.end()
|
|
} else {
|
|
recorder.begin()
|
|
}
|
|
}
|
|
if recorder.isRecording {
|
|
Text("按 Esc 取消")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
Text("可以设置单个修饰键(如右 Option),也可以设置组合键(如 ⌃Space)。设置组合键时先按住修饰键再按其他键。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("触发方式")
|
|
}
|
|
|
|
Section {
|
|
Picker("按键行为", selection: $prefs.triggerMode) {
|
|
ForEach(TriggerMode.allCases) { mode in
|
|
Text(mode.title).tag(mode)
|
|
}
|
|
}
|
|
Text("录音过程中按 Esc 可立即取消,文字不会写入。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("模式")
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.onDisappear { recorder.end() }
|
|
.onAppear {
|
|
recorder.onCapture = { spec in
|
|
prefs.hotkey = spec
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 语音
|
|
|
|
struct SpeechPane: View {
|
|
@ObservedObject var prefs = Prefs.shared
|
|
@ObservedObject var models = ModelManager.shared
|
|
@ObservedObject var dictation = DictationController.shared
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
LabeledContent("识别语言", value: "中文(普通话)")
|
|
Picker("识别引擎", selection: $prefs.engineChoice) {
|
|
Text("自动(全本地降级)").tag("auto")
|
|
Text("SpeechTranscriber").tag("speech")
|
|
Text("DictationTranscriber").tag("dictation")
|
|
Text("旧版本地识别").tag("legacy")
|
|
}
|
|
.disabled(dictation.state.isBusy || isDownloading)
|
|
.onChange(of: prefs.engineChoice) { _, _ in
|
|
SpeechEngine.resetWarm()
|
|
Task { await models.refresh(); SpeechEngine.warmUp() }
|
|
}
|
|
HStack {
|
|
Text("模型状态")
|
|
Spacer()
|
|
Text(models.statusText)
|
|
.foregroundStyle(models.state.isReady ? Color.secondary : Color.orange)
|
|
}
|
|
if case .downloading(let progress) = models.state {
|
|
ProgressView(value: progress)
|
|
}
|
|
if models.state != .installed && models.state != .legacy {
|
|
HStack {
|
|
Button("下载语音模型") {
|
|
Task { await models.download() }
|
|
}
|
|
.disabled(isDownloading)
|
|
Text("首次使用需要下载系统语音模型,只需要一次。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("识别")
|
|
}
|
|
|
|
Section {
|
|
HStack(spacing: 10) {
|
|
Button(dictation.state == .recording ? "结束测试" : "开始测试") {
|
|
if dictation.state == .recording {
|
|
dictation.stop()
|
|
} else {
|
|
dictation.testMode = true
|
|
dictation.start()
|
|
}
|
|
}
|
|
.disabled(dictation.state == .starting || dictation.state == .finalizing || (dictation.state.isBusy && !dictation.testMode))
|
|
Text("测试不会写入任何输入框。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
|
|
Text(testText)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(testText.isEmpty ? Color.secondary : Color.primary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.primary.opacity(0.05))
|
|
)
|
|
.frame(minHeight: 72, alignment: .topLeading)
|
|
} header: {
|
|
Text("测试语音")
|
|
}
|
|
|
|
Section {
|
|
Toggle("自动补句号", isOn: $prefs.autoPeriod)
|
|
Text("识别结果保留系统自带标点,只在你停顿时没有句末标点的情况下补一个句号。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("后处理")
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.onChange(of: dictation.state) { _, newValue in
|
|
if newValue == .idle {
|
|
dictation.testMode = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isDownloading: Bool {
|
|
if case .downloading = models.state { return true }
|
|
return false
|
|
}
|
|
|
|
private var testText: String {
|
|
if dictation.state == .recording {
|
|
return dictation.liveText.isEmpty ? "正在听…" : dictation.liveText
|
|
}
|
|
return dictation.lastResult
|
|
}
|
|
}
|
|
|
|
// MARK: - 关于
|
|
|
|
struct AboutPane: View {
|
|
@State private var micGranted = Permissions.microphone == .granted
|
|
@State private var axGranted = Permissions.accessibility
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
HStack {
|
|
Image(nsImage: NSApp.applicationIconImage)
|
|
.resizable().frame(width: 48, height: 48)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("TinyTTS").font(.headline)
|
|
Text("轻量级语音输入 · 全部在本机识别")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(version).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
PermissionRow(
|
|
title: "麦克风",
|
|
detail: micGranted ? "已授权" : "未授权,无法录音",
|
|
granted: micGranted,
|
|
actionTitle: "打开设置"
|
|
) {
|
|
Permissions.openPrivacyPane("Privacy_Microphone")
|
|
}
|
|
|
|
PermissionRow(
|
|
title: "辅助功能",
|
|
detail: axGranted ? "已授权" : "未授权,无法监听快捷键和写入文字",
|
|
granted: axGranted,
|
|
actionTitle: "请求授权"
|
|
) {
|
|
Permissions.promptAccessibility()
|
|
Permissions.openPrivacyPane("Privacy_Accessibility")
|
|
}
|
|
|
|
Text("辅助功能授权后会自动重试热键;麦克风授权在下次录音时重新检测。")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("权限")
|
|
}
|
|
|
|
Section {
|
|
Button("打开日志目录") {
|
|
NSWorkspace.shared.open(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs"))
|
|
}
|
|
Text("快捷键 \(Prefs.shared.hotkey.label) · 语言 中文(普通话)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.onAppear {
|
|
micGranted = Permissions.microphone == .granted
|
|
axGranted = Permissions.accessibility
|
|
}
|
|
}
|
|
|
|
private var version: String {
|
|
let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
|
return "v\(v)"
|
|
}
|
|
}
|
|
|
|
struct PermissionRow: View {
|
|
var title: String
|
|
var detail: String
|
|
var granted: Bool
|
|
var actionTitle: String
|
|
var action: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(granted ? Color.green : Color.orange)
|
|
.frame(width: 7, height: 7)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(title)
|
|
Text(detail).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if !granted {
|
|
Button(actionTitle, action: action)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|