@hasna/recordings 0.1.10 → 0.1.12
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/README.md +2 -0
- package/dist/cli/index.js +342 -153
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +114 -33
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +8 -2
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +183 -52
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +20 -3
- package/scripts/install_macos_app.sh +76 -0
- package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
- package/src/native/Recordings/Package.resolved +19 -1
- package/src/native/Recordings/Package.swift +16 -5
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
- package/src/native/Recordings/build.sh +6 -6
- package/bun.lock +0 -250
- package/bunfig.toml +0 -2
- package/src/__tests__/agents.test.ts +0 -136
- package/src/__tests__/config.test.ts +0 -252
- package/src/__tests__/database.test.ts +0 -167
- package/src/__tests__/enhancer.test.ts +0 -574
- package/src/__tests__/preload.ts +0 -4
- package/src/__tests__/projects.test.ts +0 -109
- package/src/__tests__/recorder.test.ts +0 -278
- package/src/__tests__/recordings.test.ts +0 -353
- package/src/__tests__/transcriber.test.ts +0 -322
- package/src/__tests__/types.test.ts +0 -75
- package/src/cli/index.ts +0 -1115
- package/src/db/agents.ts +0 -104
- package/src/db/database.ts +0 -163
- package/src/db/pg-migrations.ts +0 -82
- package/src/db/projects.ts +0 -71
- package/src/db/recordings.ts +0 -225
- package/src/index.ts +0 -81
- package/src/lib/config.ts +0 -183
- package/src/lib/enhancer.ts +0 -167
- package/src/lib/recorder.ts +0 -198
- package/src/lib/transcriber.ts +0 -105
- package/src/mcp/index.ts +0 -464
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
- package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
- package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
- package/src/native/Recordings/test_fn.swift +0 -79
- package/src/native/Recordings/test_fn2.swift +0 -33
- package/src/native/RecordingsHelper.swift +0 -395
- package/src/types/index.ts +0 -144
- package/tsconfig.json +0 -21
- /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
|
@@ -1,15 +1,28 @@
|
|
|
1
|
+
@preconcurrency import Cocoa
|
|
1
2
|
import SwiftUI
|
|
2
3
|
import KeyboardShortcuts
|
|
4
|
+
import RecordingsLib
|
|
3
5
|
|
|
4
6
|
@main
|
|
5
7
|
struct RecordingsApp: App {
|
|
6
8
|
@StateObject private var engine = RecordingEngine()
|
|
7
9
|
@StateObject private var shortcuts = VoiceShortcuts()
|
|
10
|
+
@StateObject private var projectStore = ProjectStore()
|
|
11
|
+
|
|
12
|
+
init() {
|
|
13
|
+
AXIsProcessTrustedWithOptions(
|
|
14
|
+
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
15
|
+
)
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
var body: some Scene {
|
|
10
19
|
MenuBarExtra {
|
|
11
|
-
MenuBarPopover(engine: engine, shortcuts: shortcuts)
|
|
12
|
-
.frame(width: 320, height:
|
|
20
|
+
MenuBarPopover(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
21
|
+
.frame(width: 320, height: 420)
|
|
22
|
+
.onAppear {
|
|
23
|
+
engine.projectStore = projectStore
|
|
24
|
+
engine.voiceShortcuts = shortcuts
|
|
25
|
+
}
|
|
13
26
|
} label: {
|
|
14
27
|
if engine.isRecording {
|
|
15
28
|
Image(systemName: "record.circle.fill")
|
|
@@ -22,9 +35,8 @@ struct RecordingsApp: App {
|
|
|
22
35
|
}
|
|
23
36
|
.menuBarExtraStyle(.window)
|
|
24
37
|
|
|
25
|
-
// Settings window — opened via SettingsLink
|
|
26
38
|
Settings {
|
|
27
|
-
SettingsView(engine: engine, shortcuts: shortcuts)
|
|
39
|
+
SettingsView(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
28
40
|
}
|
|
29
41
|
}
|
|
30
42
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"originHash" : "
|
|
2
|
+
"originHash" : "99390ba8c7a8c4d4b60ff9bc6f60500c0cc28740d0a509ae347d8785b97022b5",
|
|
3
3
|
"pins" : [
|
|
4
4
|
{
|
|
5
5
|
"identity" : "keyboardshortcuts",
|
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
"revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
|
|
10
10
|
"version" : "2.4.0"
|
|
11
11
|
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"identity" : "swift-syntax",
|
|
15
|
+
"kind" : "remoteSourceControl",
|
|
16
|
+
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
|
17
|
+
"state" : {
|
|
18
|
+
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
|
19
|
+
"version" : "600.0.1"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"identity" : "swift-testing",
|
|
24
|
+
"kind" : "remoteSourceControl",
|
|
25
|
+
"location" : "https://github.com/apple/swift-testing.git",
|
|
26
|
+
"state" : {
|
|
27
|
+
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
|
28
|
+
"version" : "0.99.0"
|
|
29
|
+
}
|
|
12
30
|
}
|
|
13
31
|
],
|
|
14
32
|
"version" : 3
|
|
@@ -8,14 +8,25 @@ let package = Package(
|
|
|
8
8
|
.macOS(.v26)
|
|
9
9
|
],
|
|
10
10
|
dependencies: [
|
|
11
|
-
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0")
|
|
11
|
+
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
|
|
12
|
+
.package(url: "https://github.com/apple/swift-testing.git", .upToNextMinor(from: "0.99.0")),
|
|
12
13
|
],
|
|
13
14
|
targets: [
|
|
14
|
-
.
|
|
15
|
-
name: "
|
|
15
|
+
.target(
|
|
16
|
+
name: "RecordingsLib",
|
|
16
17
|
dependencies: ["KeyboardShortcuts"],
|
|
17
|
-
path: "
|
|
18
|
+
path: "RecordingsLib",
|
|
18
19
|
exclude: ["Info.plist", "Recordings.entitlements"]
|
|
19
|
-
)
|
|
20
|
+
),
|
|
21
|
+
.executableTarget(
|
|
22
|
+
name: "App",
|
|
23
|
+
dependencies: ["RecordingsLib"],
|
|
24
|
+
path: "App"
|
|
25
|
+
),
|
|
26
|
+
.testTarget(
|
|
27
|
+
name: "RecordingsTests",
|
|
28
|
+
dependencies: ["RecordingsLib", .product(name: "Testing", package: "swift-testing")],
|
|
29
|
+
path: "RecordingsTests"
|
|
30
|
+
),
|
|
20
31
|
]
|
|
21
32
|
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import Cocoa
|
|
2
|
+
|
|
3
|
+
/// Monitors the fn/Globe key using CGEventTap.
|
|
4
|
+
/// Fn is exposed as a modifier flag, so we watch flagsChanged events and
|
|
5
|
+
/// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
|
|
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
|
+
|
|
17
|
+
/// Start monitoring. Returns true if successful.
|
|
18
|
+
func start() -> Bool {
|
|
19
|
+
guard eventTap == nil else {
|
|
20
|
+
fputs("[FnKeyMonitor] Already running\n", stderr)
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
|
|
25
|
+
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
|
26
|
+
let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
|
|
27
|
+
(.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
|
|
28
|
+
(.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
|
|
29
|
+
(.cghidEventTap, .headInsertEventTap, "hid/head"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (tapLocation, tapPlacement, label) in candidates {
|
|
33
|
+
fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
|
|
34
|
+
|
|
35
|
+
guard let tap = CGEvent.tapCreate(
|
|
36
|
+
tap: tapLocation,
|
|
37
|
+
place: tapPlacement,
|
|
38
|
+
options: .defaultTap,
|
|
39
|
+
eventsOfInterest: eventMask,
|
|
40
|
+
callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
|
|
41
|
+
guard let refcon else { return Unmanaged.passRetained(event) }
|
|
42
|
+
let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
|
|
43
|
+
return monitor.handleEvent(type: type, event: event)
|
|
44
|
+
},
|
|
45
|
+
userInfo: selfPtr
|
|
46
|
+
) else {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
eventTap = tap
|
|
51
|
+
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
52
|
+
if let runLoopSource {
|
|
53
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
|
54
|
+
}
|
|
55
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
56
|
+
fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
|
|
57
|
+
|
|
58
|
+
// Health check: macOS can silently disable taps — re-enable every 3 seconds.
|
|
59
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
60
|
+
guard let self, let tap = self.eventTap else { return }
|
|
61
|
+
if !CGEvent.tapIsEnabled(tap: tap) {
|
|
62
|
+
fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
|
|
63
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func stop() {
|
|
75
|
+
healthCheckTimer?.invalidate()
|
|
76
|
+
healthCheckTimer = nil
|
|
77
|
+
|
|
78
|
+
if let tap = eventTap {
|
|
79
|
+
CGEvent.tapEnable(tap: tap, enable: false)
|
|
80
|
+
}
|
|
81
|
+
if let source = runLoopSource {
|
|
82
|
+
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
83
|
+
}
|
|
84
|
+
eventTap = nil
|
|
85
|
+
runLoopSource = nil
|
|
86
|
+
fnIsDown = false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
|
90
|
+
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
91
|
+
if let tap = eventTap {
|
|
92
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
93
|
+
}
|
|
94
|
+
return Unmanaged.passRetained(event)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
guard type == .flagsChanged else {
|
|
98
|
+
return Unmanaged.passRetained(event)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
|
|
102
|
+
let fnPressed = event.flags.contains(.maskSecondaryFn)
|
|
103
|
+
let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
|
|
104
|
+
|
|
105
|
+
guard isFnTransition else {
|
|
106
|
+
return Unmanaged.passRetained(event)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let flags = event.flags.rawValue
|
|
110
|
+
fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
|
|
111
|
+
|
|
112
|
+
if fnPressed && !fnIsDown {
|
|
113
|
+
fnIsDown = true
|
|
114
|
+
fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
|
|
115
|
+
DispatchQueue.main.async { [weak self] in
|
|
116
|
+
self?.onFnKeyDown?()
|
|
117
|
+
}
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if !fnPressed && fnIsDown {
|
|
122
|
+
fnIsDown = false
|
|
123
|
+
DispatchQueue.main.async { [weak self] in
|
|
124
|
+
self?.onFnKeyUp?()
|
|
125
|
+
}
|
|
126
|
+
return nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Unmanaged.passRetained(event)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -8,15 +8,21 @@
|
|
|
8
8
|
<string>Recordings</string>
|
|
9
9
|
<key>CFBundleDisplayName</key>
|
|
10
10
|
<string>Recordings</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>Recordings</string>
|
|
11
13
|
<key>CFBundleIdentifier</key>
|
|
12
14
|
<string>com.hasna.recordings</string>
|
|
13
15
|
<key>CFBundleVersion</key>
|
|
14
16
|
<string>1</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>APPL</string>
|
|
15
19
|
<key>CFBundleShortVersionString</key>
|
|
16
20
|
<string>0.1.0</string>
|
|
17
21
|
<key>LSMinimumSystemVersion</key>
|
|
18
22
|
<string>26.0</string>
|
|
19
23
|
<key>NSMicrophoneUsageDescription</key>
|
|
20
24
|
<string>Recordings needs microphone access to record and transcribe your speech.</string>
|
|
25
|
+
<key>NSAppleEventsUsageDescription</key>
|
|
26
|
+
<string>Recordings needs Apple Events access to paste transcribed text into your active app.</string>
|
|
21
27
|
</dict>
|
|
22
28
|
</plist>
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import KeyboardShortcuts
|
|
3
|
+
|
|
4
|
+
public struct MenuBarPopover: View {
|
|
5
|
+
@ObservedObject public var engine: RecordingEngine
|
|
6
|
+
@ObservedObject public var shortcuts: VoiceShortcuts
|
|
7
|
+
@ObservedObject public var projectStore: ProjectStore
|
|
8
|
+
@State private var copiedIndex: Int?
|
|
9
|
+
@State private var filterProjectId: String?
|
|
10
|
+
|
|
11
|
+
public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
|
|
12
|
+
self.engine = engine
|
|
13
|
+
self.shortcuts = shortcuts
|
|
14
|
+
self.projectStore = projectStore
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private var filteredTranscriptions: [TranscriptionResult] {
|
|
18
|
+
guard let filter = filterProjectId else {
|
|
19
|
+
return engine.recentTranscriptions
|
|
20
|
+
}
|
|
21
|
+
if filter == "__none__" {
|
|
22
|
+
return engine.recentTranscriptions.filter { $0.projectId == nil }
|
|
23
|
+
}
|
|
24
|
+
return engine.recentTranscriptions.filter { $0.projectId == filter }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public var body: some View {
|
|
28
|
+
VStack(spacing: 0) {
|
|
29
|
+
header
|
|
30
|
+
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
31
|
+
Divider()
|
|
32
|
+
modePicker
|
|
33
|
+
.padding(.horizontal, 16).padding(.top, 10)
|
|
34
|
+
recordingArea
|
|
35
|
+
.padding(.horizontal, 16).padding(.top, 8).padding(.bottom, 12)
|
|
36
|
+
Divider()
|
|
37
|
+
if !engine.recentTranscriptions.isEmpty {
|
|
38
|
+
filterBar
|
|
39
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
40
|
+
Divider()
|
|
41
|
+
}
|
|
42
|
+
recentList
|
|
43
|
+
.frame(maxHeight: 200)
|
|
44
|
+
Divider()
|
|
45
|
+
footerMenu
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Header
|
|
50
|
+
|
|
51
|
+
private var header: some View {
|
|
52
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
53
|
+
HStack {
|
|
54
|
+
Image(systemName: "mic.fill")
|
|
55
|
+
.foregroundStyle(.tint)
|
|
56
|
+
Text("Hasna Recordings")
|
|
57
|
+
Spacer()
|
|
58
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
59
|
+
Text(shortcut.description)
|
|
60
|
+
.foregroundStyle(.secondary)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if !projectStore.settings.projects.isEmpty {
|
|
64
|
+
HStack(spacing: 4) {
|
|
65
|
+
Image(systemName: "folder.fill")
|
|
66
|
+
.foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
|
|
67
|
+
Menu {
|
|
68
|
+
Button("None") { projectStore.setActive(nil) }
|
|
69
|
+
Divider()
|
|
70
|
+
ForEach(projectStore.settings.projects) { project in
|
|
71
|
+
Button {
|
|
72
|
+
projectStore.setActive(project.id)
|
|
73
|
+
} label: {
|
|
74
|
+
HStack {
|
|
75
|
+
Text(project.name)
|
|
76
|
+
if project.id == projectStore.settings.activeProjectId {
|
|
77
|
+
Image(systemName: "checkmark")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} label: {
|
|
83
|
+
Text(projectStore.activeProject?.name ?? "No project")
|
|
84
|
+
.foregroundStyle(projectStore.activeProject == nil ? .secondary : .primary)
|
|
85
|
+
}
|
|
86
|
+
.menuStyle(.borderlessButton)
|
|
87
|
+
.fixedSize()
|
|
88
|
+
Spacer()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MARK: - Recording Area
|
|
95
|
+
|
|
96
|
+
private var modePicker: some View {
|
|
97
|
+
Picker("Mode", selection: $engine.mode) {
|
|
98
|
+
ForEach(RecordingMode.allCases) { mode in
|
|
99
|
+
Image(systemName: mode.icon)
|
|
100
|
+
.tag(mode)
|
|
101
|
+
.help(mode.rawValue)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
.pickerStyle(.segmented)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@ViewBuilder
|
|
108
|
+
private var recordingArea: some View {
|
|
109
|
+
if engine.isRecording {
|
|
110
|
+
VStack(spacing: 6) {
|
|
111
|
+
HStack {
|
|
112
|
+
Circle().fill(.red).frame(width: 8, height: 8)
|
|
113
|
+
Text(fmt(engine.recordingDuration))
|
|
114
|
+
.monospacedDigit()
|
|
115
|
+
Spacer()
|
|
116
|
+
Button("Stop") { engine.stopAndTranscribe() }
|
|
117
|
+
.controlSize(.small)
|
|
118
|
+
}
|
|
119
|
+
// Live streaming transcription text
|
|
120
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
121
|
+
Text(engine.liveTranscriptionText)
|
|
122
|
+
.font(.callout)
|
|
123
|
+
.foregroundStyle(.primary)
|
|
124
|
+
.multilineTextAlignment(.leading)
|
|
125
|
+
.lineLimit(3)
|
|
126
|
+
.padding(.vertical, 4)
|
|
127
|
+
Text("Listening...")
|
|
128
|
+
.font(.caption)
|
|
129
|
+
.foregroundStyle(.green)
|
|
130
|
+
}
|
|
131
|
+
if let project = projectStore.activeProject {
|
|
132
|
+
HStack(spacing: 4) {
|
|
133
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
134
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
135
|
+
Spacer()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
.padding(10)
|
|
140
|
+
.glassEffect(.regular)
|
|
141
|
+
} else if engine.isTranscribing {
|
|
142
|
+
VStack(spacing: 6) {
|
|
143
|
+
HStack(spacing: 8) {
|
|
144
|
+
ProgressView().controlSize(.small)
|
|
145
|
+
Text("Transcribing...")
|
|
146
|
+
.foregroundStyle(.secondary)
|
|
147
|
+
Spacer()
|
|
148
|
+
}
|
|
149
|
+
// Show accumulated text during transcribing
|
|
150
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
151
|
+
Text(engine.liveTranscriptionText)
|
|
152
|
+
.font(.callout)
|
|
153
|
+
.foregroundStyle(.primary)
|
|
154
|
+
.multilineTextAlignment(.leading)
|
|
155
|
+
.lineLimit(3)
|
|
156
|
+
.padding(.vertical, 4)
|
|
157
|
+
}
|
|
158
|
+
if let project = projectStore.activeProject {
|
|
159
|
+
HStack(spacing: 4) {
|
|
160
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
161
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
162
|
+
Spacer()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
.frame(maxWidth: .infinity)
|
|
167
|
+
.padding(10)
|
|
168
|
+
.glassEffect(.regular)
|
|
169
|
+
} else {
|
|
170
|
+
VStack(spacing: 6) {
|
|
171
|
+
Button { engine.startRecording() } label: {
|
|
172
|
+
Label("Record", systemImage: "mic.circle.fill")
|
|
173
|
+
}
|
|
174
|
+
.controlSize(.regular)
|
|
175
|
+
|
|
176
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
177
|
+
Text("or hold \(shortcut.description)")
|
|
178
|
+
.foregroundStyle(.tertiary)
|
|
179
|
+
}
|
|
180
|
+
Text(engine.statusMessage)
|
|
181
|
+
.font(.caption)
|
|
182
|
+
.foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
|
|
183
|
+
.multilineTextAlignment(.center)
|
|
184
|
+
.lineLimit(2)
|
|
185
|
+
}
|
|
186
|
+
.frame(maxWidth: .infinity)
|
|
187
|
+
.padding(10)
|
|
188
|
+
.glassEffect(.regular)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// MARK: - Filter Bar
|
|
193
|
+
|
|
194
|
+
private var filterBar: some View {
|
|
195
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
196
|
+
HStack(spacing: 6) {
|
|
197
|
+
FilterChip(label: "All", isActive: filterProjectId == nil) {
|
|
198
|
+
filterProjectId = nil
|
|
199
|
+
}
|
|
200
|
+
ForEach(projectStore.settings.projects) { project in
|
|
201
|
+
let count = engine.recentTranscriptions.filter { $0.projectId == project.id }.count
|
|
202
|
+
if count > 0 {
|
|
203
|
+
FilterChip(
|
|
204
|
+
label: "\(project.name) (\(count))",
|
|
205
|
+
isActive: filterProjectId == project.id
|
|
206
|
+
) {
|
|
207
|
+
filterProjectId = filterProjectId == project.id ? nil : project.id
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let noProjectCount = engine.recentTranscriptions.filter { $0.projectId == nil }.count
|
|
212
|
+
if noProjectCount > 0 {
|
|
213
|
+
FilterChip(
|
|
214
|
+
label: "No project (\(noProjectCount))",
|
|
215
|
+
isActive: filterProjectId == "__none__"
|
|
216
|
+
) {
|
|
217
|
+
filterProjectId = filterProjectId == "__none__" ? nil : "__none__"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MARK: - Recent
|
|
225
|
+
|
|
226
|
+
private var recentList: some View {
|
|
227
|
+
Group {
|
|
228
|
+
if filteredTranscriptions.isEmpty {
|
|
229
|
+
Spacer()
|
|
230
|
+
} else {
|
|
231
|
+
ScrollView {
|
|
232
|
+
LazyVStack(alignment: .leading, spacing: 2) {
|
|
233
|
+
ForEach(filteredTranscriptions.indices, id: \.self) { i in
|
|
234
|
+
let item = filteredTranscriptions[i]
|
|
235
|
+
TranscriptionRow(
|
|
236
|
+
item: item,
|
|
237
|
+
showProject: filterProjectId == nil,
|
|
238
|
+
isCopied: copiedIndex == i
|
|
239
|
+
) {
|
|
240
|
+
NSPasteboard.general.clearContents()
|
|
241
|
+
NSPasteboard.general.setString(item.displayText, forType: .string)
|
|
242
|
+
withAnimation(.easeInOut(duration: 0.15)) {
|
|
243
|
+
copiedIndex = i
|
|
244
|
+
}
|
|
245
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
246
|
+
withAnimation { if copiedIndex == i { copiedIndex = nil } }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// MARK: - Footer
|
|
258
|
+
|
|
259
|
+
private var footerMenu: some View {
|
|
260
|
+
VStack(spacing: 0) {
|
|
261
|
+
SettingsLink {
|
|
262
|
+
HStack {
|
|
263
|
+
Text("Settings...")
|
|
264
|
+
Spacer()
|
|
265
|
+
Text("⌘,").foregroundStyle(.tertiary)
|
|
266
|
+
}
|
|
267
|
+
.contentShape(Rectangle())
|
|
268
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
269
|
+
}
|
|
270
|
+
.buttonStyle(.plain)
|
|
271
|
+
|
|
272
|
+
Divider()
|
|
273
|
+
|
|
274
|
+
Button {
|
|
275
|
+
NSApplication.shared.terminate(nil)
|
|
276
|
+
} label: {
|
|
277
|
+
HStack {
|
|
278
|
+
Text("Quit Hasna Recordings")
|
|
279
|
+
Spacer()
|
|
280
|
+
Text("⌘Q").foregroundStyle(.tertiary)
|
|
281
|
+
}
|
|
282
|
+
.contentShape(Rectangle())
|
|
283
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
284
|
+
}
|
|
285
|
+
.buttonStyle(.plain)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func fmt(_ t: TimeInterval) -> String {
|
|
290
|
+
String(format: "%d:%02d", Int(t) / 60, Int(t) % 60)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// MARK: - Filter Chip
|
|
295
|
+
|
|
296
|
+
struct FilterChip: View {
|
|
297
|
+
let label: String
|
|
298
|
+
let isActive: Bool
|
|
299
|
+
let action: () -> Void
|
|
300
|
+
|
|
301
|
+
var body: some View {
|
|
302
|
+
Button(action: action) {
|
|
303
|
+
Text(label)
|
|
304
|
+
.padding(.horizontal, 8)
|
|
305
|
+
.padding(.vertical, 3)
|
|
306
|
+
.background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
307
|
+
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
308
|
+
}
|
|
309
|
+
.buttonStyle(.plain)
|
|
310
|
+
.foregroundStyle(isActive ? .primary : .secondary)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// MARK: - Transcription Row
|
|
315
|
+
|
|
316
|
+
struct TranscriptionRow: View {
|
|
317
|
+
let item: TranscriptionResult
|
|
318
|
+
let showProject: Bool
|
|
319
|
+
let isCopied: Bool
|
|
320
|
+
let onCopy: () -> Void
|
|
321
|
+
|
|
322
|
+
var body: some View {
|
|
323
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
324
|
+
HStack(alignment: .top, spacing: 8) {
|
|
325
|
+
Text(item.displayText)
|
|
326
|
+
.lineLimit(2)
|
|
327
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
328
|
+
|
|
329
|
+
if isCopied {
|
|
330
|
+
Image(systemName: "checkmark")
|
|
331
|
+
.foregroundStyle(.green)
|
|
332
|
+
.transition(.scale.combined(with: .opacity))
|
|
333
|
+
} else {
|
|
334
|
+
Text(relativeTime(item.timestamp))
|
|
335
|
+
.foregroundStyle(.tertiary)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if showProject, let name = item.projectName {
|
|
339
|
+
HStack(spacing: 3) {
|
|
340
|
+
Image(systemName: "folder")
|
|
341
|
+
Text(name)
|
|
342
|
+
}
|
|
343
|
+
.foregroundStyle(.tertiary)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
.padding(.vertical, 4)
|
|
347
|
+
.padding(.horizontal, 6)
|
|
348
|
+
.contentShape(Rectangle())
|
|
349
|
+
.onTapGesture { onCopy() }
|
|
350
|
+
.onHover { hovering in
|
|
351
|
+
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func relativeTime(_ date: Date) -> String {
|
|
356
|
+
let s = Date().timeIntervalSince(date)
|
|
357
|
+
if s < 60 { return "now" }
|
|
358
|
+
if s < 3600 { return "\(Int(s / 60))m" }
|
|
359
|
+
return "\(Int(s / 3600))h"
|
|
360
|
+
}
|
|
361
|
+
}
|