378 lines
13 KiB
Swift
378 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)
|
|
.animation(
|
|
.easeOut(duration: 0.5)
|
|
.speed(windowManager.isVisible ? 1.0 : 0.8), // Slightly slower when hiding
|
|
value: windowManager.isVisible
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 isRecording {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(Color.white)
|
|
.frame(width: 8, height: 8)
|
|
} else if isProcessing {
|
|
ProcessingIndicator(color: .white)
|
|
.frame(width: 14, height: 14)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.disabled(isProcessing)
|
|
}
|
|
|
|
private var buttonColor: Color {
|
|
if isProcessing {
|
|
return Color(red: 0.4, green: 0.4, blue: 0.45)
|
|
} else if isRecording {
|
|
return .red
|
|
} else {
|
|
return Color(red: 0.4, green: 0.4, blue: 0.45)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
} |