vOOice/VoiceInk/Views/NotchRecorderView.swift

373 lines
13 KiB
Swift

import SwiftUI
struct NotchRecorderView: View {
@ObservedObject var whisperState: WhisperState
@ObservedObject var audioEngine: AudioEngine
@EnvironmentObject var windowManager: NotchWindowManager
@State private var isHovering = false
@State private var showPromptPopover = false
private var menuBarHeight: CGFloat {
if let screen = NSScreen.main {
if screen.safeAreaInsets.top > 0 {
return screen.safeAreaInsets.top
}
return NSApplication.shared.mainMenu?.menuBarHeight ?? NSStatusBar.system.thickness
}
return NSStatusBar.system.thickness
}
// Calculate exact notch width
private var exactNotchWidth: CGFloat {
if let screen = NSScreen.main {
// On MacBooks with notch, safeAreaInsets.left represents half the notch width
if screen.safeAreaInsets.left > 0 {
// Multiply by 2 because safeAreaInsets.left is half the notch width
return screen.safeAreaInsets.left * 2
}
// Fallback for non-notched Macs - use a standard width
return 200
}
return 200 // Default fallback
}
var body: some View {
Group {
if windowManager.isVisible {
HStack(spacing: 0) {
// Left side group with fixed width
HStack(spacing: 8) {
// Record Button
NotchRecordButton(
isRecording: whisperState.isRecording,
isProcessing: whisperState.isProcessing
) {
Task { await whisperState.toggleRecord() }
}
.frame(width: 22)
// AI Enhancement Toggle
if let enhancementService = whisperState.getEnhancementService() {
NotchToggleButton(
isEnabled: enhancementService.isEnhancementEnabled,
icon: "sparkles",
color: .blue
) {
enhancementService.isEnhancementEnabled.toggle()
}
.frame(width: 22)
.disabled(!enhancementService.isConfigured)
}
}
.frame(width: 44) // Fixed width for controls
.padding(.leading, 16)
// Center section with exact notch width
Rectangle()
.fill(Color.clear)
.frame(width: exactNotchWidth)
.contentShape(Rectangle()) // Make the entire area tappable
// Right side group with fixed width
HStack(spacing: 8) {
// Custom Prompt Toggle and Selector
if let enhancementService = whisperState.getEnhancementService() {
NotchToggleButton(
isEnabled: enhancementService.isEnhancementEnabled,
icon: enhancementService.activePrompt?.icon.rawValue ?? "text.badge.checkmark",
color: .green
) {
showPromptPopover.toggle()
}
.frame(width: 22)
.disabled(!enhancementService.isEnhancementEnabled)
.popover(isPresented: $showPromptPopover, arrowEdge: .bottom) {
NotchPromptPopover(enhancementService: enhancementService)
}
}
// Visualizer
Group {
if whisperState.isProcessing {
NotchStaticVisualizer(color: .white)
} else {
NotchAudioVisualizer(
audioLevel: audioEngine.audioLevel,
color: .white,
isActive: whisperState.isRecording
)
}
}
.frame(width: 22)
}
.frame(width: 44) // Fixed width for controls
.padding(.trailing, 16)
}
.frame(height: menuBarHeight)
.frame(maxWidth: windowManager.isVisible ? .infinity : 0)
.background(Color.black)
.mask {
NotchShape(cornerRadius: 10)
}
.clipped()
.onHover { hovering in
isHovering = hovering
}
.opacity(windowManager.isVisible ? 1 : 0)
}
}
}
}
// Popover view for prompt selection
struct NotchPromptPopover: View {
@ObservedObject var enhancementService: AIEnhancementService
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Select Mode")
.font(.headline)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal)
.padding(.top, 8)
Divider()
.background(Color.white.opacity(0.1))
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(enhancementService.allPrompts) { prompt in
NotchPromptRow(prompt: prompt, isSelected: enhancementService.selectedPromptId == prompt.id) {
enhancementService.setActivePrompt(prompt)
}
}
}
.padding(.horizontal)
}
}
.frame(width: 180)
.frame(maxHeight: 300)
.padding(.vertical, 8)
.background(Color.black)
.environment(\.colorScheme, .dark)
}
}
// Row view for each prompt
struct NotchPromptRow: View {
let prompt: CustomPrompt
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: prompt.icon.rawValue)
.foregroundColor(isSelected ? .green : .white.opacity(0.8))
.font(.system(size: 12))
Text(prompt.title)
.foregroundColor(.white.opacity(0.9))
.font(.system(size: 13))
.lineLimit(1)
if isSelected {
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.green)
.font(.system(size: 10))
}
}
.contentShape(Rectangle())
.padding(.vertical, 4)
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
.background(isSelected ? Color.white.opacity(0.1) : Color.clear)
.cornerRadius(4)
}
}
// New toggle button component matching the notch aesthetic
struct NotchToggleButton: View {
let isEnabled: Bool
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Circle()
.fill(isEnabled ? color.opacity(0.2) : Color(red: 0.4, green: 0.4, blue: 0.45).opacity(0.2))
.frame(width: 20, height: 20)
Image(systemName: icon)
.font(.system(size: 10, weight: .medium))
.foregroundColor(isEnabled ? color : .white.opacity(0.6))
}
}
.buttonStyle(PlainButtonStyle())
}
}
struct CustomScaleModifier: ViewModifier {
let scale: CGFloat
let opacity: CGFloat
func body(content: Content) -> some View {
content
.scaleEffect(scale, anchor: .center)
.opacity(opacity)
}
}
// Notch-specific button styles
struct NotchRecordButton: View {
let isRecording: Bool
let isProcessing: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Circle()
.fill(buttonColor)
.frame(width: 22, height: 22)
if isProcessing {
ProcessingIndicator(color: .white)
.frame(width: 14, height: 14)
} else {
// Show white square for both idle and recording states
RoundedRectangle(cornerRadius: 3)
.fill(Color.white)
.frame(width: 8, height: 8)
}
}
}
.buttonStyle(PlainButtonStyle())
.disabled(isProcessing)
}
private var buttonColor: Color {
if isProcessing {
return Color(red: 0.4, green: 0.4, blue: 0.45)
} else {
// Use red color for both idle and recording states
return .red
}
}
}
struct NotchAudioVisualizer: View {
let audioLevel: CGFloat
let color: Color
let isActive: Bool
private let barCount = 5
private let minHeight: CGFloat = 3
private let maxHeight: CGFloat = 18
private let audioThreshold: CGFloat = 0.01
@State private var barHeights: [CGFloat]
init(audioLevel: CGFloat, color: Color, isActive: Bool) {
self.audioLevel = audioLevel
self.color = color
self.isActive = isActive
_barHeights = State(initialValue: Array(repeating: minHeight, count: 5))
}
var body: some View {
HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(height: barHeights[index], color: color)
}
}
.onReceive(Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()) { _ in
if isActive && audioLevel > audioThreshold {
updateBars()
} else {
resetBars()
}
}
}
private func updateBars() {
for i in 0..<barCount {
let targetHeight = calculateTargetHeight(for: i)
let speed = CGFloat.random(in: 0.4...0.8)
barHeights[i] += (targetHeight - barHeights[i]) * speed
}
}
private func resetBars() {
for i in 0..<barCount {
barHeights[i] = minHeight
}
}
private func calculateTargetHeight(for index: Int) -> CGFloat {
let normalizedLevel = max(0, audioLevel - audioThreshold)
let amplifiedLevel = pow(normalizedLevel, 0.6)
let baseHeight = amplifiedLevel * maxHeight * 1.7
let variation = CGFloat.random(in: -2...2)
let positionFactor = CGFloat(index) / CGFloat(barCount - 1)
let curve = sin(positionFactor * .pi)
return max(minHeight, min(baseHeight * curve + variation, maxHeight))
}
}
struct NotchVisualizerBar: View {
let height: CGFloat
let color: Color
var body: some View {
RoundedRectangle(cornerRadius: 1.5)
.fill(
LinearGradient(
gradient: Gradient(colors: [
color.opacity(0.6),
color.opacity(0.8),
color
]),
startPoint: .bottom,
endPoint: .top
)
)
.frame(width: 2, height: height)
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: height)
}
}
struct NotchStaticVisualizer: View {
private let barCount = 5
private let barHeights: [CGFloat] = [0.7, 0.5, 0.8, 0.4, 0.6]
let color: Color
var body: some View {
HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(height: barHeights[index] * 18, color: color)
}
}
}
}
struct ProcessingIndicator: View {
@State private var rotation: Double = 0
let color: Color
var body: some View {
Circle()
.trim(from: 0.1, to: 0.9)
.stroke(color, lineWidth: 1.5)
.frame(width: 12, height: 12)
.rotationEffect(.degrees(rotation))
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotation = 360
}
}
}
}