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.. audioThreshold { updateBars() } else { resetBars() } } } private func updateBars() { for i in 0.. 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..