@hasna/recordings 0.3.13 → 0.4.0

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.
Files changed (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -4,6 +4,12 @@ import Darwin
4
4
  import SwiftUI
5
5
  import KeyboardShortcuts
6
6
  import RecordingsLib
7
+ import Combine
8
+
9
+ private final class RecorderPanel: NSPanel {
10
+ override var canBecomeKey: Bool { true }
11
+ override var canBecomeMain: Bool { true }
12
+ }
7
13
 
8
14
  private final class PermissionRequestResultBox: @unchecked Sendable {
9
15
  private let lock = NSLock()
@@ -63,7 +69,14 @@ final class RecordingsAppState: ObservableObject {
63
69
  private let runtimeSmokeOutputPath: String?
64
70
  private let runtimeSmokeAcknowledgementPath: String?
65
71
  private let runtimeSmokeCompletionPath: String?
72
+ @Published var page: RecorderPage = .recorder
66
73
  private var mainWindow: NSWindow?
74
+ private var settingsWindowController: SettingsWindowController?
75
+ private var historyWindow: NSWindow?
76
+ private var transcriptionBar: NSPanel?
77
+ private var recentPastesWindow: NSWindow?
78
+ private var subscriptions = Set<AnyCancellable>()
79
+ private var compactSettingsWindow: NSWindow?
67
80
  private(set) var windowCreationCount = 0
68
81
  private(set) var windowActivationCount = 0
69
82
 
@@ -77,8 +90,28 @@ final class RecordingsAppState: ObservableObject {
77
90
  runtimeSmokeCompletionPath = plan.runtimeSmokeCompletionPath
78
91
  runtimeSmokeProbe = plan.runtimeSmokeMode == "normal" ? RuntimeSmokeProbe() : nil
79
92
  if plan.installsGlobalHandlers {
80
- let store = RecordingsStore()
93
+ let store: RecordingsStore
94
+ #if DEBUG
95
+ if CommandLine.arguments.contains("--visual-preview") {
96
+ let home = NSTemporaryDirectory() + "recordings-design-preview"
97
+ store = RecordingsStore(engine: RecordingEngine(homePath: home, installsGlobalHandlers: false),
98
+ preferences: ProjectStore(filePath: home + "/projects.json"), voiceShortcuts: VoiceShortcuts(homePath: home))
99
+ store.isVisualPreview = true
100
+ let labels = ["Interview notes", "Project idea", "Quick thought", "Meeting recap", "Voice memo"]
101
+ let durations = [737000, 343000, 68000, 1691000, 156000]
102
+ store.library = labels.enumerated().map { i, name in
103
+ Recording(id: "visual-preview-\(i)", audioPath: ProcessInfo.processInfo.environment["RECORDINGS_VISUAL_PREVIEW_AUDIO"], rawText: name, durationMs: durations[i],
104
+ createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-i * 86400))))
105
+ }
106
+ if CommandLine.arguments.contains("--visual-preview-recording") {
107
+ store.engine.configureVisualPreview()
108
+ }
109
+ } else { store = RecordingsStore() }
110
+ #else
111
+ store = RecordingsStore()
112
+ #endif
81
113
  self.store = store
114
+ store.$showMenuBar.sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &subscriptions)
82
115
  if plan.declaresMainWindow {
83
116
  Task { @MainActor [weak self] in
84
117
  self?.openRecordings()
@@ -90,6 +123,8 @@ final class RecordingsAppState: ObservableObject {
90
123
  }
91
124
 
92
125
  func openRecordings() {
126
+ page = .recorder
127
+ resizeDesktop()
93
128
  // Bar launches never create the workspace window — on any path, including the
94
129
  // reopen handler and the runtime smoke. Keyed on declaresWindow (NOT on
95
130
  // declaresMainWindow, which excludes every runtime smoke): a full build's
@@ -99,12 +134,133 @@ final class RecordingsAppState: ObservableObject {
99
134
  // deterministically on every build.
100
135
  guard declaresWindow else { return }
101
136
  if let store {
102
- showWindow(contentView: NSHostingView(rootView: ContentView(store: store)))
137
+ showWindow(contentView: NSHostingView(rootView: ContentView(store: store, state: self, windowAction: performWindowAction)))
103
138
  } else if runtimeSmokeMode == "normal" {
104
139
  showWindow(contentView: NSHostingView(rootView: Text("Recordings runtime smoke")))
105
140
  }
106
141
  }
107
142
 
143
+ private func showPage(_ destination: RecorderPage) {
144
+ if mainWindow == nil { openRecordings() }
145
+ page = destination
146
+ resizeDesktop()
147
+ activate(mainWindow)
148
+ }
149
+
150
+ private func resizeDesktop() {
151
+ guard let mainWindow else { return }
152
+ mainWindow.contentMinSize = page.size
153
+ mainWindow.contentMaxSize = page.size
154
+ mainWindow.setContentSize(page.size)
155
+ }
156
+
157
+ private func performWindowAction(_ kind: NSWindow.ButtonType) {
158
+ switch kind {
159
+ case .closeButton: mainWindow?.performClose(nil)
160
+ case .miniaturizeButton: mainWindow?.miniaturize(nil)
161
+ case .zoomButton: mainWindow?.toggleFullScreen(nil)
162
+ default: break
163
+ }
164
+ }
165
+
166
+ func openBar() {
167
+ guard let store else { return }
168
+ if transcriptionBar == nil {
169
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
170
+ let width = min(820, screen.width - 48) + 16
171
+ let panel = RecorderPanel(contentRect: NSRect(x: screen.midX - width / 2, y: screen.maxY - 84, width: width, height: 76),
172
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
173
+ panel.title = "Hasna Recordings — Live Transcript"
174
+ panel.isOpaque = false; panel.backgroundColor = .clear
175
+ panel.level = .floating; panel.hasShadow = true
176
+ panel.isMovableByWindowBackground = true
177
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
178
+ panel.isReleasedWhenClosed = false
179
+ panel.contentView = NSHostingView(rootView: MenuBarStatusView(store: store, openRecordings: openRecordings,
180
+ openSettings: openSettings, openHistory: openHistory, openRecent: openRecent,
181
+ closeBar: { [weak self] in self?.transcriptionBar?.orderOut(nil) }, barOnly: barOnly))
182
+ transcriptionBar = panel
183
+ }
184
+ transcriptionBar?.makeKeyAndOrderFront(nil)
185
+ }
186
+
187
+ func openRecent() {
188
+ guard let store else { return }
189
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
190
+ let width = min(760, screen.width - 48)
191
+ if recentPastesWindow == nil {
192
+ recentPastesWindow = makePanel(title: "Recent pastes", width: width, height: 350,
193
+ content: NSHostingView(rootView: VStack(spacing: 0) {
194
+ PanelPointer().fill(Color(red: 0.94, green: 0.95, blue: 0.97))
195
+ .frame(width: 26, height: 12).offset(x: width * 0.34)
196
+ RecentPastesView(store: store, close: { [weak self] in self?.recentPastesWindow?.close() })
197
+ .frame(width: width, height: 338)
198
+ }))
199
+ recentPastesWindow?.level = .floating
200
+ }
201
+ let barBottom = transcriptionBar?.frame.minY ?? (screen.maxY - 84)
202
+ recentPastesWindow?.setFrameOrigin(NSPoint(x: screen.midX - width / 2, y: barBottom - 356))
203
+ activate(recentPastesWindow)
204
+ }
205
+
206
+ func openHistory() {
207
+ guard let store else { return }
208
+ store.selection = nil
209
+ store.searchText = ""
210
+ if !barOnly { showPage(.history); return }
211
+ store.searchText = ""
212
+ if historyWindow == nil {
213
+ historyWindow = makePanel(title: "Recordings", width: 500, height: 354,
214
+ content: NSHostingView(rootView: RecordingsListView(store: store, close: { [weak self] in self?.historyWindow?.close() }).background(FrostedBackground())))
215
+ }
216
+ store.loadLibrary()
217
+ activate(historyWindow)
218
+ }
219
+
220
+ func openSettings() {
221
+ guard let store else { return }
222
+ if !barOnly { showPage(.settings); return }
223
+ if compactSettingsWindow == nil {
224
+ compactSettingsWindow = makePanel(title: "Hasna Recordings Settings", width: 420, height: 314,
225
+ content: NSHostingView(rootView: RecorderSettingsView(store: store,
226
+ close: { [weak self] in self?.compactSettingsWindow?.close() }, advanced: openAdvancedSettings).background(FrostedBackground())))
227
+ }
228
+ activate(compactSettingsWindow)
229
+ }
230
+
231
+ private func makePanel(title: String, width: CGFloat, height: CGFloat, content: NSView) -> NSWindow {
232
+ let panel = RecorderPanel(contentRect: NSRect(x: 0, y: 0, width: width, height: height),
233
+ styleMask: [.borderless, .resizable], backing: .buffered, defer: false)
234
+ panel.title = title
235
+ panel.isReleasedWhenClosed = false
236
+ panel.isMovableByWindowBackground = true
237
+ panel.isOpaque = false; panel.backgroundColor = .clear; panel.hasShadow = true
238
+ panel.appearance = NSAppearance(named: .aqua)
239
+ panel.contentView = content
240
+ panel.center()
241
+ return panel
242
+ }
243
+
244
+ private func activate(_ window: NSWindow?) {
245
+ NSApplication.shared.activate()
246
+ window?.makeKeyAndOrderFront(nil)
247
+ }
248
+
249
+ func openAdvancedSettings() {
250
+ guard let store, !store.isVisualPreview else { return }
251
+ if !barOnly { showPage(.advanced); return }
252
+ if settingsWindowController == nil {
253
+ settingsWindowController = SettingsWindowController {
254
+ NSHostingView(rootView: SettingsView(
255
+ engine: store.engine,
256
+ shortcuts: store.voiceShortcuts,
257
+ preferences: store.preferences
258
+ ))
259
+ }
260
+ }
261
+ settingsWindowController?.show()
262
+ }
263
+
108
264
  private func showWindow(contentView: NSView) {
109
265
  windowActivationCount += 1
110
266
  NSApplication.shared.setActivationPolicy(.regular)
@@ -116,22 +272,31 @@ final class RecordingsAppState: ObservableObject {
116
272
  }
117
273
 
118
274
  let window = NSWindow(
119
- contentRect: NSRect(x: 0, y: 0, width: 1180, height: 760),
275
+ contentRect: NSRect(x: 0, y: 0, width: 224, height: 244),
120
276
  styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
121
277
  backing: .buffered,
122
278
  defer: false
123
279
  )
124
- window.title = "Recordings"
280
+ window.title = "Hasna Recordings"
125
281
  window.titleVisibility = .hidden
126
282
  window.titlebarAppearsTransparent = true
283
+ window.isOpaque = false
284
+ window.backgroundColor = .clear
285
+ window.isMovableByWindowBackground = true
286
+ window.appearance = NSAppearance(named: .aqua)
127
287
  window.isReleasedWhenClosed = false
128
288
  window.contentView = contentView
289
+ window.contentMinSize = NSSize(width: 224, height: 244)
290
+ window.contentMaxSize = NSSize(width: 224, height: 244)
129
291
  window.center()
130
292
  mainWindow = window
131
293
  windowCreationCount += 1
132
294
  NSApplication.shared.activate()
133
295
  NSRunningApplication.current.activate(options: [.activateAllWindows])
134
296
  window.makeKeyAndOrderFront(nil)
297
+ for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
298
+ window.standardWindowButton(kind)?.isHidden = true
299
+ }
135
300
  }
136
301
 
137
302
  func startRuntimeSmokeIfNeeded() {
@@ -351,6 +516,10 @@ struct RecordingsApp: App {
351
516
  MenuBarStatusView(
352
517
  store: store,
353
518
  openRecordings: state.openRecordings,
519
+ openSettings: state.openSettings,
520
+ openHistory: state.openHistory,
521
+ openRecent: state.openRecent,
522
+ openBar: state.openBar,
354
523
  barOnly: state.barOnly
355
524
  )
356
525
  } else if state.runtimeSmokeProbe != nil {
@@ -370,23 +539,30 @@ struct RecordingsApp: App {
370
539
 
371
540
  Settings {
372
541
  if let store = state.store {
373
- SettingsView(engine: store.engine, shortcuts: store.voiceShortcuts, projectStore: store.projectStore)
542
+ SettingsView(engine: store.engine, shortcuts: store.voiceShortcuts, preferences: store.preferences)
374
543
  } else {
375
544
  EmptyView()
376
545
  }
377
546
  }
378
547
  .commands {
379
548
  if let store = state.store {
549
+ CommandGroup(replacing: .appSettings) {
550
+ Button("Settings…", action: state.openSettings)
551
+ .keyboardShortcut(",", modifiers: .command)
552
+ }
380
553
  CommandGroup(replacing: .newItem) {
381
554
  Button("New Recording") {
382
- store.pane = .record
383
- store.engine.startRecording()
555
+ self.state.openRecordings()
556
+ store.beginRecording()
384
557
  }
385
558
  .keyboardShortcut("n", modifiers: .command)
559
+ .disabled(!store.engine.canStartRecording)
386
560
  }
387
561
  CommandGroup(after: .toolbar) {
388
- Button("Recordings Library") { store.pane = .library }
562
+ Button("Recordings") { state.openHistory() }
389
563
  .keyboardShortcut("l", modifiers: .command)
564
+ Button("Show Transcription Bar", action: state.openBar)
565
+ .keyboardShortcut("b", modifiers: [.command, .shift])
390
566
  }
391
567
  }
392
568
  }
@@ -394,7 +570,7 @@ struct RecordingsApp: App {
394
570
 
395
571
  private var menuBarInsertion: Binding<Bool> {
396
572
  Binding(
397
- get: { state.declaresMenuBar && (state.store != nil || state.runtimeSmokeProbe != nil) },
573
+ get: { state.declaresMenuBar && (state.store?.showMenuBar == true || state.runtimeSmokeProbe != nil) },
398
574
  set: { _ in }
399
575
  )
400
576
  }
@@ -1,130 +1,78 @@
1
1
  import SwiftUI
2
2
  import RecordingsLib
3
3
 
4
- /// The recordings library list: a search field, then a flat list of transcripts on the white
5
- /// canvas, separated by hairline dividers. Selecting a row shows it in the detail pane.
6
4
  struct RecordingsListView: View {
7
5
  @ObservedObject var store: RecordingsStore
6
+ var close: () -> Void = {}
7
+ @State private var searching = false
8
8
 
9
9
  var body: some View {
10
- VStack(spacing: 0) {
11
- searchRow
12
- Divider().opacity(0.5)
13
- list
14
- }
15
- }
16
-
17
- private var searchRow: some View {
18
- HStack(spacing: 8) {
19
- Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
20
- TextField("Search transcripts", text: $store.searchText)
21
- .textFieldStyle(.plain)
22
- .font(.system(.body, design: .rounded))
23
- if !store.searchText.isEmpty {
24
- Button { store.searchText = "" } label: {
25
- Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
26
- }
27
- .buttonStyle(.plain)
10
+ VStack(spacing: 10) {
11
+ HStack {
12
+ Button(action: close) { Image(systemName: "chevron.left") }.help("Back to recorder").accessibilityLabel("Back to recorder")
13
+ Text("Recordings").font(.system(size: 13, weight: .semibold))
14
+ Spacer()
15
+ Button { searching.toggle() } label: { Image(systemName: "magnifyingglass") }
16
+ .help("Search transcripts").accessibilityLabel("Search transcripts")
17
+ Button { store.loadLibrary() } label: { Image(systemName: "arrow.clockwise") }
18
+ .help("Refresh recordings").accessibilityLabel("Refresh recordings")
28
19
  }
29
- }
30
- .padding(.horizontal, 16).padding(.vertical, 10)
31
- }
32
-
33
- @ViewBuilder
34
- private var list: some View {
35
- if store.isLoadingLibrary && store.library.isEmpty {
36
- centered { ProgressView() }
37
- } else if let error = store.loadError, store.library.isEmpty {
38
- centered {
39
- VStack(spacing: 8) {
40
- Image(systemName: "exclamationmark.triangle").font(.largeTitle).foregroundStyle(.orange)
41
- Text(error).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
42
- Button("Retry") { store.loadLibrary() }
43
- }
44
- .padding(24)
20
+ .buttonStyle(GlassButtonStyle()).foregroundStyle(Theme.accent)
21
+ .padding(.horizontal, 2)
22
+ if searching && store.selectedRecording == nil {
23
+ TextField("Search transcripts", text: $store.searchText).textFieldStyle(.plain).padding(7).background(GlassInset()).padding(.horizontal, 12)
45
24
  }
46
- } else if store.visibleRecordings.isEmpty {
47
- centered {
48
- VStack(spacing: 8) {
49
- Image(systemName: "waveform").font(.largeTitle).foregroundStyle(.quaternary)
50
- Text(emptyMessage).foregroundStyle(.secondary).multilineTextAlignment(.center)
51
- Button("Record") { store.pane = .record }.buttonStyle(.borderless)
25
+ if store.selectedRecording != nil {
26
+ HStack {
27
+ Button { store.selection = nil } label: { Label("All recordings", systemImage: "chevron.left") }.buttonStyle(GlassButtonStyle())
28
+ Spacer()
52
29
  }
53
- }
54
- } else {
55
- ScrollView {
56
- LazyVStack(spacing: 0) {
57
- ForEach(store.visibleRecordings) { rec in
58
- RecordingRow(rec: rec,
59
- projectName: store.projectName(rec.projectId),
60
- selected: store.selection == rec.id)
61
- .contentShape(Rectangle())
62
- .onTapGesture { store.selection = rec.id }
63
- .contextMenu {
64
- Button("Copy") {
65
- let pb = NSPasteboard.general
66
- pb.clearContents(); pb.setString(rec.displayText, forType: .string)
67
- }
68
- Button("Paste into front app") { store.engine.pasteIntoFrontApp(rec.displayText) }
69
- Divider()
70
- Button("Delete", role: .destructive) { store.delete(id: rec.id) }
30
+ RecordingDetailView(store: store)
31
+ } else if store.isLoadingLibrary && store.library.isEmpty {
32
+ Spacer(); ProgressView(); Spacer()
33
+ } else if let error = store.loadError, store.library.isEmpty {
34
+ Spacer()
35
+ Text(error.contains("HASNA_RECORDINGS") ? "Connect your Recordings API in Settings to see your history." : error)
36
+ .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).padding()
37
+ Button("Retry") { store.loadLibrary() }; Spacer()
38
+ } else if store.visibleRecordings.isEmpty {
39
+ Spacer(); Text(store.searchText.isEmpty ? "No recordings yet" : "No matching recordings").foregroundStyle(.secondary); Spacer()
40
+ } else {
41
+ ScrollView {
42
+ LazyVStack(spacing: 2) {
43
+ ForEach(store.visibleRecordings) { rec in
44
+ HStack(spacing: 12) {
45
+ GlassIconButton(symbol: store.playbackRecordingID == rec.id && store.isPlaying ? "pause.fill" : "play.fill", label: "Play recording", size: 28) { store.play(rec) }
46
+ .disabled(!store.canPlay(rec) || store.engine.captureIsActive)
47
+ .help(store.canPlay(rec) ? "Play recording" : "Audio is not stored on this Mac")
48
+ Button { store.selection = rec.id } label: {
49
+ VStack(alignment: .leading, spacing: 5) {
50
+ Text(rec.snippet).font(.system(size: 13)).lineLimit(1)
51
+ Text(rec.createdDate?.recordingDateLabel ?? "Recording").font(.system(size: 11)).foregroundStyle(.secondary)
52
+ }
53
+ .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle())
54
+ }.buttonStyle(.plain)
55
+ Text(Theme.clock(rec.durationSeconds)).font(.system(size: 12)).monospacedDigit().foregroundStyle(.secondary)
56
+ Menu {
57
+ Button("Open transcript") { store.selection = rec.id }
58
+ Button("Copy transcript") { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(rec.displayText, forType: .string) }
59
+ Button("Delete recording", role: .destructive) { store.delete(id: rec.id) }
60
+ } label: { Image(systemName: "ellipsis").font(.system(size: 13)) }
61
+ .menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 28)
71
62
  }
72
- Divider().opacity(0.35).padding(.leading, 16)
63
+ .padding(.horizontal, 10).padding(.vertical, 8)
64
+ .background(GlassInset(radius: 10))
65
+ }
73
66
  }
74
67
  }
75
68
  }
76
69
  }
77
- }
78
-
79
- private var emptyMessage: String {
80
- if !store.searchText.isEmpty { return "No matches for “\(store.searchText)”" }
81
- switch store.filter {
82
- case .project(let id):
83
- return "No recordings in \(store.projectName(id) ?? "this project") yet"
84
- case .noProject: return "No unassigned recordings"
85
- case .mode(let m): return "No \(m) recordings yet"
86
- case .thisMachine, .machine: return "No recordings from this machine"
87
- case .all: return "No recordings yet"
88
- }
89
- }
90
-
91
- @ViewBuilder
92
- private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
93
- VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity, maxHeight: .infinity)
94
- }
95
- }
96
-
97
- private struct RecordingRow: View {
98
- let rec: Recording
99
- let projectName: String?
100
- let selected: Bool
70
+ .padding(12)
101
71
 
102
- var body: some View {
103
- VStack(alignment: .leading, spacing: 5) {
104
- Text(rec.snippet)
105
- .font(.system(.headline, design: .rounded)).lineLimit(2)
106
- HStack(spacing: 6) {
107
- if let date = rec.createdDate {
108
- Text(date.relativeDescription).font(.caption2).foregroundStyle(.secondary)
109
- }
110
- if rec.durationMs > 0 {
111
- Text("·").font(.caption2).foregroundStyle(.secondary)
112
- Text(rec.durationLabel).font(.caption2).foregroundStyle(.secondary)
113
- }
114
- if rec.isEnhanced {
115
- Text("·").font(.caption2).foregroundStyle(.secondary)
116
- Label("Enhanced", systemImage: "wand.and.stars")
117
- .labelStyle(.titleAndIcon).font(.caption2).foregroundStyle(Theme.accent.opacity(0.9))
118
- }
119
- if let projectName {
120
- Text("·").font(.caption2).foregroundStyle(.secondary)
121
- Label(projectName, systemImage: "folder").labelStyle(.titleAndIcon)
122
- .font(.caption2).foregroundStyle(.secondary)
123
- }
124
- }
125
- }
126
- .padding(.horizontal, 16).padding(.vertical, 11)
127
- .frame(maxWidth: .infinity, alignment: .leading)
128
- .background { if selected { Theme.accent.opacity(0.12) } }
72
+ .frame(minWidth: 500, minHeight: 354)
73
+ .onAppear { store.loadLibrary() }
74
+ .alert("Recordings Error", isPresented: Binding(get: { store.operationError != nil }, set: { if !$0 { store.operationError = nil } })) {
75
+ Button("OK") { store.operationError = nil }
76
+ } message: { Text(store.operationError ?? "The operation failed.") }
129
77
  }
130
78
  }