Files
Grant Gilliam dda4322de7 Reconcile docs/ specs with the shipped app
Document the dual-channel label-merge path (mic_file/system_file/self_name/self_vad) and the recap phase (transcript.md + recap.html via the backend LLM) across docs/01-03; correct docs/02 $2.10 to the UI actually shipped; mark docs/01 $7 open items as settled; remove the dead AUDIO_API.md references; note the manifest sha256 fields are not emitted; mark docs/04 as a complete/historical build log. Also drop the last stale "Phase 0" UI string in MenuBarView and retire the now-done doc-debt items in ROADMAP.
2026-06-16 22:09:04 -05:00

259 lines
8.9 KiB
Swift

import SwiftUI
import AppKit
/// The menu-bar panel: permission statuses, backend health, and a link to
/// Settings. Shown when the user clicks the status-bar item.
struct MenuBarView: View {
@EnvironmentObject private var settings: AppSettings
@EnvironmentObject private var permissions: PermissionsManager
@EnvironmentObject private var health: SparkControlHealth
@EnvironmentObject private var session: SessionController
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
Divider()
recordingSection
Divider()
permissionsSection
Divider()
backendSection
Divider()
footer
}
.padding(14)
.frame(width: 320)
.onAppear { permissions.refresh() }
.task { await refreshHealth() }
}
// MARK: Recording
private var canRecord: Bool {
permissions.microphone == .granted && permissions.screenRecording == .granted
}
private var recordingSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recording").font(.subheadline).bold()
Spacer()
if session.state == .recording {
Text(timeString(session.elapsed))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
Text(detectionText)
.font(.caption)
.foregroundStyle(.secondary)
Button {
session.toggle()
} label: {
Label(recordButtonTitle, systemImage: recordButtonIcon)
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.tint(session.state == .recording ? .red : .accentColor)
.disabled(recordButtonDisabled)
if session.state == .recording {
LevelBar(label: "Mic", level: session.micLevel)
LevelBar(label: "System", level: session.systemLevel)
}
if !canRecord && !session.isBusy {
Text("Grant Microphone + Screen Recording above to record.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .error(let message) = session.state {
Text(message).font(.caption).foregroundStyle(.red)
}
if let warning = session.warning {
Text(warning).font(.caption).foregroundStyle(.orange)
}
if let last = session.lastSession {
Button {
NSWorkspace.shared.activateFileViewerSelecting([last.mixedURL])
} label: {
Text("Last: \(Int(last.duration.rounded()))s · \(last.selfSpanCount) self-spans · \(last.visualSegmentCount.map { "\($0) visual segments" } ?? "audio-only") — reveal in Finder")
.font(.caption)
}
.buttonStyle(.link)
HStack {
Button("Send to backend") { session.processLastSession() }
.disabled(transcriptProcessing)
if let recap = session.recapURL {
Button("Open recap") { NSWorkspace.shared.open(recap) }
}
if session.canEditSpeakers {
Button("Edit speakers") { session.editLastSession() }
}
Spacer()
}
}
HStack(spacing: 6) {
Button("Open saved session…") { session.openSavedSession() }
.buttonStyle(.link).font(.caption)
.disabled(transcriptProcessing)
if transcriptProcessing { ProgressView().controlSize(.small) }
}
// Always-visible status (covers "Send to backend" AND "Open saved session").
if !transcriptText.isEmpty {
Text(transcriptText).font(.caption).foregroundStyle(transcriptColor)
}
}
}
private var recordButtonTitle: String {
switch session.state {
case .starting: return "Starting…"
case .recording: return "Stop Recording"
case .finishing: return "Finishing…"
case .idle, .error: return "Start Recording"
}
}
private var recordButtonIcon: String {
session.state == .recording ? "stop.circle.fill" : "record.circle"
}
private var recordButtonDisabled: Bool {
switch session.state {
case .starting, .finishing: return true
case .recording: return false
case .idle, .error: return !canRecord
}
}
private func timeString(_ t: TimeInterval) -> String {
let total = Int(t)
return String(format: "%02d:%02d", total / 60, total % 60)
}
private var detectionText: String {
switch session.detectionStatus {
case .disabled: return "Auto-detect off"
case .listening: return "Listening for calls…"
case .inCall(let app): return "In call: \(app.display)"
}
}
private var transcriptProcessing: Bool {
if case .processing = session.transcriptStatus { return true }
return false
}
private var transcriptText: String {
switch session.transcriptStatus {
case .idle: return ""
case .processing(_, let t) where t == 0: return "Working… (this can take a few minutes)"
case .processing(let d, let t): return "Transcribing… chunk \(d)/\(t)"
case .done(let s, let seg): return "Transcript ready · \(s) speakers · \(seg) segments"
case .failed(let m): return "Transcript failed: \(m)"
}
}
private var transcriptColor: Color {
switch session.transcriptStatus {
case .failed: return .red
case .done: return .green
default: return .secondary
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Ten31 Transcripts").font(.headline)
Text("Setup & status")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Permissions").font(.subheadline).bold()
PermissionRow(
title: "Microphone",
state: permissions.microphone,
onGrant: permissions.requestMicrophone,
onOpenSettings: { permissions.openSettings(.microphone) }
)
PermissionRow(
title: "Screen Recording",
state: permissions.screenRecording,
onGrant: permissions.requestScreenRecording,
onOpenSettings: { permissions.openSettings(.screenRecording) }
)
PermissionRow(
title: "Accessibility",
state: permissions.accessibility,
onGrant: permissions.requestAccessibility,
onOpenSettings: { permissions.openSettings(.accessibility) }
)
}
}
private var backendSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Backend").font(.subheadline).bold()
Spacer()
Button("Check") { Task { await refreshHealth() } }
.disabled(health.status == .checking)
}
Text(settings.backendBaseURL)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 8) {
StatusDot(color: healthColor)
Text(healthText).font(.caption)
}
}
}
private var footer: some View {
HStack {
Button("Settings…") { SettingsWindow.shared.show(settings: settings) }
Spacer()
Button("Quit") { NSApplication.shared.terminate(nil) }
}
}
private func refreshHealth() async {
await health.check(
baseURL: settings.backendBaseURL,
skipTLS: settings.skipTLSVerification
)
}
private var healthColor: Color {
switch health.status {
case .online: return .green
case .offline: return .red
case .checking: return .orange
case .unknown: return .gray
}
}
private var healthText: String {
switch health.status {
case .unknown: return "Not checked yet"
case .checking: return "Checking…"
case .online(let detail): return "Online · \(detail)"
case .offline(let error): return "Offline · \(error)"
}
}
}