Files
tinystt/Sources/TinyTTS/UI/SettingsView.swift
T

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)
}
}
}
}