import Cocoa import QuartzCore public enum HUDState { case hidden case recording(level: Float, isLagging: Bool = false) case retrying case transcribing case success case error } public final class HUDOverlayController { public static let shared = HUDOverlayController() private var window: NSPanel? private var orbView: SolidSiriOrbView? private var isVisible = false // Large transparent canvas so audio-reactive halo never clips private let canvasSize: CGFloat = 200 private init() { DispatchQueue.main.async { self.setupWindow() } } private func setupWindow() { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: canvasSize, height: canvasSize), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isOpaque = false panel.backgroundColor = .clear panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] panel.hasShadow = false panel.ignoresMouseEvents = true panel.alphaValue = 0.0 let container = NSView(frame: panel.contentView!.bounds) container.wantsLayer = true // Minimal Solid Orb centered in canvas let orbDiameter: CGFloat = 50 let orbOrigin = (canvasSize - orbDiameter) / 2.0 let orb = SolidSiriOrbView(frame: NSRect(x: orbOrigin, y: orbOrigin, width: orbDiameter, height: orbDiameter)) container.addSubview(orb) self.orbView = orb panel.contentView = container self.window = panel reposition() } private func reposition() { guard let window = self.window, let screen = NSScreen.main else { return } let screenRect = screen.visibleFrame // Center horizontally, positioned comfortably higher above the dock let x = screenRect.midX - (canvasSize / 2.0) let y = screenRect.minY + 75 window.setFrameOrigin(NSPoint(x: x, y: y)) } public func show(state: HUDState) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.window == nil { self.setupWindow() } guard let window = self.window, let orb = self.orbView else { return } self.reposition() switch state { case .hidden: self.hide() case .recording(let level, let isLagging): if !self.isVisible { window.orderFrontRegardless() window.animator().alphaValue = 1.0 self.isVisible = true } orb.updateAudioLevel(level, isLagging: isLagging) case .retrying: if !self.isVisible { window.orderFrontRegardless() window.animator().alphaValue = 1.0 self.isVisible = true } orb.showRetryState() case .transcribing: orb.showTranscribingState() case .success: orb.showSuccessState() DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { self.hide() } case .error: orb.showErrorState() DispatchQueue.main.asyncAfter(deadline: .now() + 0.70) { self.hide() } } } } public func hide() { DispatchQueue.main.async { [weak self] in guard let self = self, let window = self.window, self.isVisible else { return } NSAnimationContext.runAnimationGroup({ context in context.duration = 0.18 window.animator().alphaValue = 0.0 }, completionHandler: { window.orderOut(nil) self.isVisible = false self.orbView?.reset() }) } } } // MARK: - Minimal Solid-Color Orb with 100% Dynamic Audio-Driven Stroke & Bloom Halo final class SolidSiriOrbView: NSView { private let glowLayer = CALayer() private let solidCoreLayer = CALayer() private let strokeBorderLayer = CALayer() private let maxStrokeWidth: CGFloat = 8.0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true setupLayers() } required init?(coder: NSCoder) { super.init(coder: coder) wantsLayer = true setupLayers() } private func setupLayers() { guard let layer = self.layer else { return } layer.masksToBounds = false let bounds = self.bounds let radius = bounds.width / 2.0 // 1. Dynamic Outer Atmospheric Glow (100% audio-driven; disappears completely in silence) glowLayer.frame = bounds.insetBy(dx: -12, dy: -12) glowLayer.cornerRadius = (bounds.width + 24) / 2.0 glowLayer.backgroundColor = NSColor.clear.cgColor glowLayer.shadowColor = NSColor.white.cgColor glowLayer.shadowRadius = 0 glowLayer.shadowOpacity = 0.0 glowLayer.shadowOffset = .zero glowLayer.opacity = 0.0 layer.addSublayer(glowLayer) // 2. Chic, Minimal Solid-Color Core (Apple Obsidian Graphite Disc) solidCoreLayer.frame = bounds solidCoreLayer.cornerRadius = radius solidCoreLayer.backgroundColor = NSColor(red: 0.12, green: 0.12, blue: 0.14, alpha: 0.98).cgColor layer.addSublayer(solidCoreLayer) // 3. Dynamic Voice-Reactive Stroke Ring (Disappears completely in silence) strokeBorderLayer.frame = bounds strokeBorderLayer.cornerRadius = radius strokeBorderLayer.borderWidth = 0.0 strokeBorderLayer.borderColor = NSColor.white.cgColor strokeBorderLayer.backgroundColor = NSColor.clear.cgColor strokeBorderLayer.opacity = 0.0 layer.addSublayer(strokeBorderLayer) } /// Updates audio response: /// In silence (level <= 0.04): stroke and halo disappear completely. /// When speaking: stroke widens powerfully (up to 8px) and halo blooms dynamically. func updateAudioLevel(_ level: Float, isLagging: Bool) { CATransaction.begin() CATransaction.setDisableActions(true) let rawClamped = CGFloat(min(max(level, 0.0), 1.0)) let silenceThreshold: CGFloat = 0.04 if rawClamped <= silenceThreshold { // Complete silence: Stroke and Halo completely vanish strokeBorderLayer.borderWidth = 0.0 strokeBorderLayer.opacity = 0.0 glowLayer.shadowOpacity = 0.0 glowLayer.shadowRadius = 0.0 glowLayer.opacity = 0.0 } else { // Active speech: scale dynamic intensity noticeably let norm = (rawClamped - silenceThreshold) / (1.0 - silenceThreshold) if isLagging { // Amber highlight for network degradation let amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 0.95).cgColor strokeBorderLayer.borderColor = amber glowLayer.shadowColor = amber } else { // Crisp luminous white glow strokeBorderLayer.borderColor = NSColor.white.cgColor glowLayer.shadowColor = NSColor.white.cgColor } // 1. Noticeably powerful stroke expansion (from 0 to 8px) let dynamicWidth = norm * maxStrokeWidth strokeBorderLayer.borderWidth = dynamicWidth strokeBorderLayer.opacity = Float(0.40 + (0.60 * norm)) // 2. Dynamic bloom halo (expands up to 45px radius) glowLayer.shadowRadius = 6.0 + (norm * 38.0) glowLayer.shadowOpacity = Float(0.35 + (0.65 * norm)) glowLayer.opacity = Float(norm) } CATransaction.commit() } func showRetryState() { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.20) strokeBorderLayer.borderWidth = 4.0 strokeBorderLayer.opacity = 1.0 strokeBorderLayer.borderColor = NSColor.orange.cgColor glowLayer.shadowColor = NSColor.orange.cgColor glowLayer.shadowRadius = 24 glowLayer.shadowOpacity = 0.9 glowLayer.opacity = 1.0 CATransaction.commit() } func showTranscribingState() { CATransaction.begin() CATransaction.setDisableActions(true) strokeBorderLayer.borderWidth = 3.0 strokeBorderLayer.opacity = 0.8 strokeBorderLayer.borderColor = NSColor.white.cgColor glowLayer.shadowColor = NSColor.white.cgColor glowLayer.shadowRadius = 16 glowLayer.shadowOpacity = 0.7 glowLayer.opacity = 0.8 CATransaction.commit() } func showSuccessState() { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) let green = NSColor(red: 0.15, green: 0.95, blue: 0.45, alpha: 1.0).cgColor strokeBorderLayer.borderWidth = 4.0 strokeBorderLayer.opacity = 1.0 strokeBorderLayer.borderColor = green glowLayer.shadowColor = green glowLayer.shadowRadius = 22 glowLayer.shadowOpacity = 0.9 glowLayer.opacity = 1.0 CATransaction.commit() } func showErrorState() { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) let red = NSColor(red: 1.0, green: 0.20, blue: 0.20, alpha: 1.0).cgColor strokeBorderLayer.borderWidth = 4.0 strokeBorderLayer.opacity = 1.0 strokeBorderLayer.borderColor = red glowLayer.shadowColor = red glowLayer.shadowRadius = 22 glowLayer.shadowOpacity = 0.9 glowLayer.opacity = 1.0 CATransaction.commit() } func reset() { CATransaction.begin() CATransaction.setDisableActions(true) strokeBorderLayer.borderWidth = 0.0 strokeBorderLayer.opacity = 0.0 glowLayer.shadowRadius = 0.0 glowLayer.shadowOpacity = 0.0 glowLayer.opacity = 0.0 CATransaction.commit() } }