Files
ten31-transcripts/Ten31Transcripts/UI/MenuBarView.swift
T
Grant Gilliam 9a18664429 Open saved session: visible progress + clear errors (no silent no-op)
The status line only rendered inside the last-in-memory-session block, so 'Open
saved session' processed invisibly — looked like nothing happened. Now: the
transcript status (with a spinner) is always shown, the processing(0,0) reconcile
phase reads 'Working… (this can take a few minutes)', and invalid picks surface an
alert (not a recorded session / already processing / unreadable transcript) instead
of doing nothing.
2026-06-08 12:16:52 -05:00

263 lines
9.0 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 {
NavigationStack {
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("Phase 0 · 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 {
NavigationLink("Settings…") {
SettingsView()
}
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)"
}
}
}