cdf833935a
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
171 lines
6.2 KiB
Swift
171 lines
6.2 KiB
Swift
// MacDictationOverlayView.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
|
|
// non-activating NSPanel so it never steals focus from the front app.
|
|
// One-line layout: status / live transcript preview + waveform + stop.
|
|
|
|
import SwiftUI
|
|
|
|
struct MacDictationOverlayView: View {
|
|
@ObservedObject var viewModel: MacDictationViewModel
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
|
|
|
private var isBusy: Bool {
|
|
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
|
|
}
|
|
|
|
/// Trimmed live / final transcript for the single-line preview.
|
|
private var previewText: String {
|
|
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private var hasPreview: Bool { !previewText.isEmpty }
|
|
|
|
private var showsLiveBadge: Bool {
|
|
viewModel.isRecording && viewModel.isStreamingPartial
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
statusDot
|
|
primaryLine
|
|
Spacer(minLength: Spacing.xs)
|
|
trailingControl
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.padding(.vertical, 11)
|
|
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
|
|
.fixedSize(horizontal: true, vertical: true)
|
|
.background(palette.surface, in: Capsule(style: .continuous))
|
|
.overlay(
|
|
Capsule(style: .continuous)
|
|
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
|
|
.padding(2)
|
|
.animation(Motion.soft, value: hasPreview)
|
|
.animation(Motion.quick, value: viewModel.isRecording)
|
|
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
|
}
|
|
|
|
// MARK: - Primary line (status or one-line transcript)
|
|
|
|
@ViewBuilder
|
|
private var primaryLine: some View {
|
|
if hasPreview {
|
|
HStack(spacing: 6) {
|
|
if showsLiveBadge {
|
|
liveBadge
|
|
}
|
|
Text(previewText)
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.lineLimit(1)
|
|
.truncationMode(.head)
|
|
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
|
|
.contentTransition(.opacity)
|
|
.animation(Motion.quick, value: previewText)
|
|
.accessibilityLabel(previewText)
|
|
}
|
|
} else {
|
|
HStack(spacing: 6) {
|
|
Text(statusText)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.contentTransition(.opacity)
|
|
if let appName = viewModel.foregroundAppName, isBusy {
|
|
Text("·")
|
|
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
|
Text(appName)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.animation(Motion.quick, value: statusText)
|
|
}
|
|
}
|
|
|
|
private var liveBadge: some View {
|
|
Text(MacL10n.string("mac.overlay.live", language: lang))
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(palette.recordRed)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 2)
|
|
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
private var statusDot: some View {
|
|
Circle()
|
|
.fill(dotColor)
|
|
.frame(width: 8, height: 8)
|
|
.animation(Motion.quick, value: viewModel.isRecording)
|
|
.animation(Motion.quick, value: viewModel.isProcessing)
|
|
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
|
|
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
|
}
|
|
|
|
private var dotColor: Color {
|
|
if viewModel.isRecording {
|
|
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
|
|
}
|
|
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
|
|
return palette.accent
|
|
}
|
|
|
|
private var statusText: String {
|
|
if viewModel.isRecording {
|
|
return MacL10n.string("mac.overlay.listening", language: lang)
|
|
}
|
|
if viewModel.isPreparingToRecord {
|
|
return MacL10n.string("mac.overlay.preparing", language: lang)
|
|
}
|
|
if viewModel.isProcessing {
|
|
if viewModel.isStreamingPartial {
|
|
return MacL10n.string("mac.overlay.polishing", language: lang)
|
|
}
|
|
return MacL10n.string("mac.overlay.transcribing", language: lang)
|
|
}
|
|
return MacL10n.string("mac.overlay.done", language: lang)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var trailingControl: some View {
|
|
if viewModel.isRecording {
|
|
MiniWaveform(
|
|
level: viewModel.audioLevel,
|
|
barCount: 7,
|
|
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
|
|
.opacity(0.9),
|
|
maxBarHeight: 28,
|
|
barWidth: 3.5,
|
|
barSpacing: 2.5
|
|
)
|
|
stopButton
|
|
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(palette.accent)
|
|
.symbolRenderingMode(.hierarchical)
|
|
}
|
|
}
|
|
|
|
private var stopButton: some View {
|
|
Button(action: viewModel.toggleRecording) {
|
|
Image(systemName: "stop.fill")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(palette.textOnAccent)
|
|
.frame(width: 28, height: 28)
|
|
.background(palette.recordRed, in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
|
|
}
|
|
}
|