@hasna/recordings 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +170 -21
- package/dist/cli/index.js +47 -25
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +30 -10
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/mcp/index.js +19 -8
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/config.test.ts +1 -1
- package/src/cli/index.ts +11 -8
- package/src/lib/config.ts +22 -5
- package/src/native/Recordings/Package.resolved +15 -0
- package/src/native/Recordings/Package.swift +21 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +122 -0
- package/src/native/Recordings/Recordings/Info.plist +22 -0
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +188 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +355 -0
- package/src/native/Recordings/Recordings/Recordings.entitlements +12 -0
- package/src/native/Recordings/Recordings/RecordingsApp.swift +30 -0
- package/src/native/Recordings/Recordings/SettingsView.swift +93 -0
- package/src/native/Recordings/Recordings/VoiceShortcuts.swift +82 -0
- package/src/native/Recordings/build.sh +40 -0
- package/src/native/Recordings/test_fn.swift +79 -0
- package/src/native/Recordings/test_fn2.swift +33 -0
- package/src/native/RecordingsHelper.swift +45 -2
package/src/cli/index.ts
CHANGED
|
@@ -485,24 +485,27 @@ program
|
|
|
485
485
|
const { existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
|
|
486
486
|
const home = getHome();
|
|
487
487
|
|
|
488
|
-
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
488
|
+
const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
|
|
489
|
+
const oldAppPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
489
490
|
|
|
490
|
-
if (!fileExists(appPath)) {
|
|
491
|
+
if (!fileExists(appPath) && !fileExists(oldAppPath)) {
|
|
491
492
|
console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
|
|
492
493
|
process.exit(1);
|
|
493
494
|
}
|
|
494
495
|
|
|
496
|
+
const resolvedAppPath = fileExists(appPath) ? appPath : oldAppPath;
|
|
497
|
+
|
|
495
498
|
// Kill existing instance
|
|
496
499
|
try { execSync("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
|
|
497
500
|
|
|
498
501
|
// Launch
|
|
499
|
-
execSync(`open "${
|
|
502
|
+
execSync(`open "${resolvedAppPath}"`, { stdio: "pipe" });
|
|
500
503
|
console.log(chalk.green("Recordings helper launched — press F5 to record"));
|
|
501
504
|
|
|
502
505
|
if (opts.login) {
|
|
503
506
|
try {
|
|
504
507
|
execSync(
|
|
505
|
-
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${
|
|
508
|
+
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${resolvedAppPath}", hidden:true}'`,
|
|
506
509
|
{ stdio: "pipe" }
|
|
507
510
|
);
|
|
508
511
|
console.log(chalk.green("Added to Login Items — will start on boot"));
|
|
@@ -688,7 +691,7 @@ program
|
|
|
688
691
|
const { homedir: getHome } = require("node:os") as typeof import("node:os");
|
|
689
692
|
const home = getHome();
|
|
690
693
|
|
|
691
|
-
const scriptDir = pathJoin(home, ".recordings");
|
|
694
|
+
const scriptDir = pathJoin(home, ".hasna", "recordings");
|
|
692
695
|
mkdirSync(scriptDir, { recursive: true });
|
|
693
696
|
|
|
694
697
|
const scriptPath = pathJoin(scriptDir, "record-toggle.sh");
|
|
@@ -747,7 +750,7 @@ fi
|
|
|
747
750
|
// Install the native menu bar app — no config needed, just works with F5
|
|
748
751
|
const { execSync: exec } = require("node:child_process") as typeof import("node:child_process");
|
|
749
752
|
|
|
750
|
-
const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
|
|
753
|
+
const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
|
|
751
754
|
const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
|
|
752
755
|
const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
|
|
753
756
|
|
|
@@ -757,10 +760,10 @@ fi
|
|
|
757
760
|
} else if (fileExists(srcSwift)) {
|
|
758
761
|
// Compile from source
|
|
759
762
|
console.log(chalk.blue("Compiling native helper app..."));
|
|
760
|
-
const appDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents", "MacOS");
|
|
763
|
+
const appDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents", "MacOS");
|
|
761
764
|
mkdirSync(appDir, { recursive: true });
|
|
762
765
|
|
|
763
|
-
const plistDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents");
|
|
766
|
+
const plistDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents");
|
|
764
767
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
765
768
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
766
769
|
<plist version="1.0"><dict>
|
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, cpSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import type { RecordingsConfig } from "../types/index.js";
|
|
@@ -67,7 +67,9 @@ export function loadConfig(configPath?: string): RecordingsConfig {
|
|
|
67
67
|
if (process.env.RECORDINGS_LANGUAGE) {
|
|
68
68
|
config.language = process.env.RECORDINGS_LANGUAGE;
|
|
69
69
|
}
|
|
70
|
-
if (process.env.
|
|
70
|
+
if (process.env.HASNA_RECORDINGS_DB_PATH) {
|
|
71
|
+
config.db_path = process.env.HASNA_RECORDINGS_DB_PATH;
|
|
72
|
+
} else if (process.env.RECORDINGS_DB_PATH) {
|
|
71
73
|
config.db_path = process.env.RECORDINGS_DB_PATH;
|
|
72
74
|
}
|
|
73
75
|
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
@@ -115,7 +117,7 @@ function findConfigFile(): string | null {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
export function getDataDir(): string {
|
|
118
|
-
// Check for .recordings in cwd hierarchy
|
|
120
|
+
// Check for .recordings in cwd hierarchy (project-local)
|
|
119
121
|
let dir = process.cwd();
|
|
120
122
|
const root = "/";
|
|
121
123
|
while (dir !== root) {
|
|
@@ -125,8 +127,23 @@ export function getDataDir(): string {
|
|
|
125
127
|
if (parent === dir) break;
|
|
126
128
|
dir = parent;
|
|
127
129
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
|
|
131
|
+
// Global: ~/.hasna/recordings (with backward compat from ~/.recordings)
|
|
132
|
+
const home = homedir();
|
|
133
|
+
const newDir = join(home, ".hasna", "recordings");
|
|
134
|
+
const oldDir = join(home, ".recordings");
|
|
135
|
+
|
|
136
|
+
// Auto-migrate from old location if new dir doesn't exist yet
|
|
137
|
+
if (!existsSync(newDir) && existsSync(oldDir)) {
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(join(home, ".hasna"), { recursive: true });
|
|
140
|
+
cpSync(oldDir, newDir, { recursive: true });
|
|
141
|
+
} catch {
|
|
142
|
+
// Fall through to use new dir
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return newDir;
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
function loadSecretKey(keyName: string): string {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"originHash" : "a6c896b6f831f2130914352c7417e95b036b1f02a1ff70318f1f4e5aa8966e9b",
|
|
3
|
+
"pins" : [
|
|
4
|
+
{
|
|
5
|
+
"identity" : "keyboardshortcuts",
|
|
6
|
+
"kind" : "remoteSourceControl",
|
|
7
|
+
"location" : "https://github.com/sindresorhus/KeyboardShortcuts",
|
|
8
|
+
"state" : {
|
|
9
|
+
"revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
|
|
10
|
+
"version" : "2.4.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"version" : 3
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// swift-tools-version: 6.2
|
|
2
|
+
|
|
3
|
+
import PackageDescription
|
|
4
|
+
|
|
5
|
+
let package = Package(
|
|
6
|
+
name: "Recordings",
|
|
7
|
+
platforms: [
|
|
8
|
+
.macOS(.v26)
|
|
9
|
+
],
|
|
10
|
+
dependencies: [
|
|
11
|
+
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0")
|
|
12
|
+
],
|
|
13
|
+
targets: [
|
|
14
|
+
.executableTarget(
|
|
15
|
+
name: "Recordings",
|
|
16
|
+
dependencies: ["KeyboardShortcuts"],
|
|
17
|
+
path: "Recordings",
|
|
18
|
+
exclude: ["Info.plist", "Recordings.entitlements"]
|
|
19
|
+
)
|
|
20
|
+
]
|
|
21
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Cocoa
|
|
2
|
+
|
|
3
|
+
/// Monitors the fn/Globe key using CGEventTap.
|
|
4
|
+
/// Based on the proven pattern from CustomWispr (open-source WisprFlow alternative).
|
|
5
|
+
/// User must set System Settings > Keyboard > "Press fn key to: Do Nothing" for this to work.
|
|
6
|
+
final class FnKeyMonitor: @unchecked Sendable {
|
|
7
|
+
var onFnKeyDown: (() -> Void)?
|
|
8
|
+
var onFnKeyUp: (() -> Void)?
|
|
9
|
+
|
|
10
|
+
private var eventTap: CFMachPort?
|
|
11
|
+
private var runLoopSource: CFRunLoopSource?
|
|
12
|
+
private var healthCheckTimer: Timer?
|
|
13
|
+
private var fnIsDown = false
|
|
14
|
+
|
|
15
|
+
private static let fnKeyCode: UInt16 = 63
|
|
16
|
+
private static let fnFlagMask: UInt64 = 0x800000
|
|
17
|
+
|
|
18
|
+
/// Start monitoring. Returns true if successful.
|
|
19
|
+
func start() -> Bool {
|
|
20
|
+
guard eventTap == nil else {
|
|
21
|
+
fputs("[FnKeyMonitor] Already running\n", stderr)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fputs("[FnKeyMonitor] Creating event tap...\n", stderr)
|
|
26
|
+
let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
|
|
27
|
+
|
|
28
|
+
// passRetained so the callback reference stays alive
|
|
29
|
+
let selfPtr = Unmanaged.passRetained(self).toOpaque()
|
|
30
|
+
|
|
31
|
+
guard let tap = CGEvent.tapCreate(
|
|
32
|
+
tap: .cgSessionEventTap,
|
|
33
|
+
place: .headInsertEventTap,
|
|
34
|
+
options: .defaultTap,
|
|
35
|
+
eventsOfInterest: eventMask,
|
|
36
|
+
callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
|
|
37
|
+
guard let refcon = refcon else { return Unmanaged.passRetained(event) }
|
|
38
|
+
let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
|
|
39
|
+
return monitor.handleEvent(type: type, event: event)
|
|
40
|
+
},
|
|
41
|
+
userInfo: selfPtr
|
|
42
|
+
) else {
|
|
43
|
+
Unmanaged<FnKeyMonitor>.fromOpaque(selfPtr).release()
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
self.eventTap = tap
|
|
48
|
+
|
|
49
|
+
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
50
|
+
self.runLoopSource = source
|
|
51
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
52
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
53
|
+
fputs("[FnKeyMonitor] Event tap created and enabled OK\n", stderr)
|
|
54
|
+
|
|
55
|
+
// Health check: macOS silently disables taps — re-enable every 3 seconds
|
|
56
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
57
|
+
guard let self = self, let tap = self.eventTap else { return }
|
|
58
|
+
if !CGEvent.tapIsEnabled(tap: tap) {
|
|
59
|
+
fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
|
|
60
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func stop() {
|
|
68
|
+
healthCheckTimer?.invalidate()
|
|
69
|
+
healthCheckTimer = nil
|
|
70
|
+
|
|
71
|
+
if let tap = eventTap {
|
|
72
|
+
CGEvent.tapEnable(tap: tap, enable: false)
|
|
73
|
+
}
|
|
74
|
+
if let source = runLoopSource {
|
|
75
|
+
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
76
|
+
}
|
|
77
|
+
eventTap = nil
|
|
78
|
+
runLoopSource = nil
|
|
79
|
+
fnIsDown = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
|
83
|
+
// Re-enable if macOS disabled the tap
|
|
84
|
+
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
85
|
+
if let tap = eventTap {
|
|
86
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
87
|
+
}
|
|
88
|
+
return Unmanaged.passRetained(event)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
|
|
92
|
+
|
|
93
|
+
// Only handle fn key (keyCode 63)
|
|
94
|
+
guard keyCode == FnKeyMonitor.fnKeyCode else {
|
|
95
|
+
return Unmanaged.passRetained(event)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let flags = event.flags.rawValue
|
|
99
|
+
let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
|
|
100
|
+
|
|
101
|
+
fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
|
|
102
|
+
|
|
103
|
+
if fnPressed && !fnIsDown {
|
|
104
|
+
// fn just pressed
|
|
105
|
+
fnIsDown = true
|
|
106
|
+
fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
|
|
107
|
+
DispatchQueue.main.async { [weak self] in
|
|
108
|
+
self?.onFnKeyDown?()
|
|
109
|
+
}
|
|
110
|
+
return nil // Swallow — prevents emoji picker / language switch
|
|
111
|
+
} else if !fnPressed && fnIsDown {
|
|
112
|
+
// fn released
|
|
113
|
+
fnIsDown = false
|
|
114
|
+
DispatchQueue.main.async { [weak self] in
|
|
115
|
+
self?.onFnKeyUp?()
|
|
116
|
+
}
|
|
117
|
+
return nil // Swallow release too
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Unmanaged.passRetained(event)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>LSUIElement</key>
|
|
6
|
+
<true/>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>Recordings</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>Recordings</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>com.hasna.recordings</string>
|
|
13
|
+
<key>CFBundleVersion</key>
|
|
14
|
+
<string>1</string>
|
|
15
|
+
<key>CFBundleShortVersionString</key>
|
|
16
|
+
<string>0.1.0</string>
|
|
17
|
+
<key>LSMinimumSystemVersion</key>
|
|
18
|
+
<string>26.0</string>
|
|
19
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
20
|
+
<string>Recordings needs microphone access to record and transcribe your speech.</string>
|
|
21
|
+
</dict>
|
|
22
|
+
</plist>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import KeyboardShortcuts
|
|
3
|
+
|
|
4
|
+
struct MenuBarPopover: View {
|
|
5
|
+
@ObservedObject var engine: RecordingEngine
|
|
6
|
+
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
+
|
|
8
|
+
var body: some View {
|
|
9
|
+
VStack(spacing: 0) {
|
|
10
|
+
header.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
11
|
+
Divider()
|
|
12
|
+
modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
|
|
13
|
+
shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
|
|
14
|
+
Divider()
|
|
15
|
+
recordingArea.padding(.horizontal, 16).padding(.vertical, 12)
|
|
16
|
+
Divider()
|
|
17
|
+
recentList.frame(maxHeight: 180)
|
|
18
|
+
Divider()
|
|
19
|
+
footer.padding(.horizontal, 16).padding(.vertical, 8)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// MARK: - Header
|
|
24
|
+
|
|
25
|
+
private var header: some View {
|
|
26
|
+
HStack {
|
|
27
|
+
Image(systemName: "mic.fill").font(.title3).foregroundStyle(.tint)
|
|
28
|
+
Text("Recordings").font(.headline)
|
|
29
|
+
Spacer()
|
|
30
|
+
Toggle(isOn: $engine.isWhisperMode) {
|
|
31
|
+
Label("Whisper", systemImage: "speaker.wave.1.fill").font(.caption)
|
|
32
|
+
}
|
|
33
|
+
.toggleStyle(.button).buttonStyle(.bordered).controlSize(.small)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MARK: - Mode Selector
|
|
38
|
+
|
|
39
|
+
private var modeSelector: some View {
|
|
40
|
+
HStack(spacing: 8) {
|
|
41
|
+
modeBtn(.pushToTalk)
|
|
42
|
+
modeBtn(.dictation)
|
|
43
|
+
modeBtn(.command)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@ViewBuilder
|
|
48
|
+
private func modeBtn(_ m: RecordingMode) -> some View {
|
|
49
|
+
if engine.mode == m {
|
|
50
|
+
Button { engine.mode = m } label: {
|
|
51
|
+
Label(m.rawValue, systemImage: m.icon).font(.caption)
|
|
52
|
+
.foregroundStyle(.white).frame(maxWidth: .infinity)
|
|
53
|
+
}.buttonStyle(.borderedProminent).controlSize(.small)
|
|
54
|
+
} else {
|
|
55
|
+
Button { engine.mode = m } label: {
|
|
56
|
+
Label(m.rawValue, systemImage: m.icon).font(.caption)
|
|
57
|
+
.frame(maxWidth: .infinity)
|
|
58
|
+
}.buttonStyle(.bordered).controlSize(.small)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Shortcut Row
|
|
63
|
+
|
|
64
|
+
private var shortcutRow: some View {
|
|
65
|
+
VStack(spacing: 6) {
|
|
66
|
+
// fn key toggle
|
|
67
|
+
HStack {
|
|
68
|
+
Toggle(isOn: $engine.useFnKey) {
|
|
69
|
+
HStack(spacing: 4) {
|
|
70
|
+
Text("fn").font(.system(.caption, design: .monospaced)).bold()
|
|
71
|
+
Text("Globe key").font(.caption).foregroundStyle(.secondary)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
.toggleStyle(.switch).controlSize(.small)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Custom shortcut
|
|
78
|
+
HStack(spacing: 8) {
|
|
79
|
+
Text("Custom").font(.caption).foregroundStyle(.secondary)
|
|
80
|
+
Spacer()
|
|
81
|
+
let current = KeyboardShortcuts.getShortcut(for: .toggleRecording)
|
|
82
|
+
Text(current?.description ?? "None")
|
|
83
|
+
.font(.system(.caption, design: .monospaced))
|
|
84
|
+
.padding(.horizontal, 8).padding(.vertical, 3)
|
|
85
|
+
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
|
|
86
|
+
|
|
87
|
+
SettingsLink {
|
|
88
|
+
Text("Set").font(.caption2)
|
|
89
|
+
}.buttonStyle(.bordered).controlSize(.mini)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MARK: - Recording Area
|
|
95
|
+
|
|
96
|
+
private var recordingArea: some View {
|
|
97
|
+
VStack(spacing: 8) {
|
|
98
|
+
if engine.isRecording {
|
|
99
|
+
HStack(spacing: 12) {
|
|
100
|
+
Circle().fill(.red).frame(width: 10, height: 10)
|
|
101
|
+
Text(fmt(engine.recordingDuration))
|
|
102
|
+
.font(.system(.title2, design: .monospaced))
|
|
103
|
+
Spacer()
|
|
104
|
+
Button("Stop") { engine.stopAndTranscribe() }
|
|
105
|
+
.buttonStyle(.borderedProminent).tint(.red).controlSize(.small)
|
|
106
|
+
}
|
|
107
|
+
.padding(12).glassEffect(.regular.tint(.red))
|
|
108
|
+
} else if engine.isTranscribing {
|
|
109
|
+
HStack(spacing: 8) {
|
|
110
|
+
ProgressView().controlSize(.small)
|
|
111
|
+
Text("Transcribing...").font(.subheadline).foregroundStyle(.secondary)
|
|
112
|
+
}
|
|
113
|
+
.frame(maxWidth: .infinity).padding(12).glassEffect(.regular)
|
|
114
|
+
} else {
|
|
115
|
+
VStack(spacing: 10) {
|
|
116
|
+
Button { engine.startRecording() } label: {
|
|
117
|
+
Label("Record", systemImage: "mic.circle.fill").font(.title3)
|
|
118
|
+
}.buttonStyle(.borderedProminent).controlSize(.large)
|
|
119
|
+
|
|
120
|
+
Text(engine.mode.hint)
|
|
121
|
+
.font(.callout).foregroundStyle(.primary.opacity(0.7))
|
|
122
|
+
.multilineTextAlignment(.center)
|
|
123
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
124
|
+
}
|
|
125
|
+
.frame(maxWidth: .infinity).padding(14).glassEffect(.clear)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Text(engine.statusMessage)
|
|
129
|
+
.font(.caption2).foregroundStyle(.secondary)
|
|
130
|
+
.lineLimit(2).truncationMode(.tail)
|
|
131
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// MARK: - Recent
|
|
136
|
+
|
|
137
|
+
private var recentList: some View {
|
|
138
|
+
Group {
|
|
139
|
+
if engine.recentTranscriptions.isEmpty {
|
|
140
|
+
VStack { Spacer()
|
|
141
|
+
Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
|
|
142
|
+
Spacer()
|
|
143
|
+
}.frame(maxWidth: .infinity)
|
|
144
|
+
} else {
|
|
145
|
+
ScrollView {
|
|
146
|
+
LazyVStack(alignment: .leading, spacing: 4) {
|
|
147
|
+
ForEach(engine.recentTranscriptions.indices, id: \.self) { i in
|
|
148
|
+
TranscriptionRow(item: engine.recentTranscriptions[i])
|
|
149
|
+
}
|
|
150
|
+
}.padding(.horizontal, 16).padding(.vertical, 6)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// MARK: - Footer
|
|
157
|
+
|
|
158
|
+
private var footer: some View {
|
|
159
|
+
HStack {
|
|
160
|
+
SettingsLink { Image(systemName: "gear") }.buttonStyle(.borderless)
|
|
161
|
+
Spacer()
|
|
162
|
+
Button("Quit") { NSApplication.shared.terminate(nil) }
|
|
163
|
+
.buttonStyle(.borderless).foregroundStyle(.secondary)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private func fmt(_ t: TimeInterval) -> String {
|
|
168
|
+
String(format: "%d:%02d", Int(t) / 60, Int(t) % 60)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
struct TranscriptionRow: View {
|
|
173
|
+
let item: TranscriptionResult
|
|
174
|
+
var body: some View {
|
|
175
|
+
HStack(alignment: .top, spacing: 8) {
|
|
176
|
+
Text(item.displayText).font(.caption).lineLimit(2)
|
|
177
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
178
|
+
Text({ let s = Date().timeIntervalSince(item.timestamp)
|
|
179
|
+
return s < 60 ? "now" : s < 3600 ? "\(Int(s/60))m" : "\(Int(s/3600))h"
|
|
180
|
+
}()).font(.caption2).foregroundStyle(.tertiary)
|
|
181
|
+
}
|
|
182
|
+
.padding(.vertical, 4).contentShape(Rectangle())
|
|
183
|
+
.onTapGesture {
|
|
184
|
+
NSPasteboard.general.clearContents()
|
|
185
|
+
NSPasteboard.general.setString(item.displayText, forType: .string)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|