import Cocoa import QuartzCore public enum HUDState { case hidden case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil) case retrying(reason: String = "RETRYING...") case transcribing case success case error(message: String = "ERROR") } public final class HUDOverlayController { public static let shared = HUDOverlayController() private var window: NSPanel? private var orbView: OutwardExpandingOrbView? private var isVisible = false // Large transparent canvas so outward expansion and shadows never clip private let canvasSize: CGFloat = 200 private init() { DispatchQueue.main.async { self.setupWindow() NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in self?.reposition() } } } 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 = .statusBar 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 let orb = OutwardExpandingOrbView(frame: NSRect(x: 0, y: 0, width: canvasSize, height: canvasSize)) 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 ?? NSScreen.screens.first else { return } let screenRect = screen.visibleFrame // Center horizontally, positioned comfortably higher above dock let x = screenRect.midX - (canvasSize / 2.0) let y = screenRect.minY + 68 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, let pingQuality): if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true } orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) case .retrying(let reason): if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true } orb.showRetryState(reason: reason) case .transcribing: if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true } orb.showTranscribingState() case .success: orb.showSuccessState() DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.hide() } case .error(let message): if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true } orb.showErrorState(message: message) DispatchQueue.main.asyncAfter(deadline: .now() + 1.40) { self.hide() } } } } public func hide() { DispatchQueue.main.async { [weak self] in guard let self = self, let window = self.window, self.isVisible else { return } self.isVisible = false NSAnimationContext.runAnimationGroup({ context in context.duration = 0.15 window.animator().alphaValue = 0.0 }, completionHandler: { if !self.isVisible { window.orderOut(nil) self.orbView?.reset() } }) } } } // MARK: - Outward Expanding Solid Orb View // Core remains completely fixed in size; Dynamic stroke expands 100% OUTWARD into the surrounding space. final class OutwardExpandingOrbView: NSView { private let coreSolidLayer = CALayer() private let dynamicStrokeLayer = CALayer() private let pingTextLayer = CATextLayer() private let baseDiameter: CGFloat = 48.0 private let minStrokeWidth: CGFloat = 2.0 private let maxStrokeWidth: CGFloat = 12.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 center = CGPoint(x: bounds.midX, y: bounds.midY) // 1. Solid Core Layer (رنگ بر اساس پینگ: سبز <450ms، زرد 450-750ms، نارنجی 750-1500ms، قرمز قطع/بالا) coreSolidLayer.bounds = CGRect(x: 0, y: 0, width: baseDiameter, height: baseDiameter) coreSolidLayer.position = center coreSolidLayer.cornerRadius = baseDiameter / 2.0 coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.95, blue: 0.20, alpha: 1.0).cgColor // Initial Warm Yellow layer.addSublayer(coreSolidLayer) // Ping Text Layer inside core pingTextLayer.bounds = CGRect(x: 0, y: 0, width: baseDiameter, height: 16.0) pingTextLayer.position = CGPoint(x: center.x, y: center.y - 1.0) pingTextLayer.fontSize = 11.0 pingTextLayer.font = NSFont.monospacedDigitSystemFont(ofSize: 11.0, weight: .bold) pingTextLayer.alignmentMode = .center pingTextLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 pingTextLayer.foregroundColor = NSColor(red: 0.10, green: 0.12, blue: 0.15, alpha: 0.95).cgColor pingTextLayer.string = "" layer.addSublayer(pingTextLayer) // 2. Outward Expanding Pastel Light Blue Stroke Layer (دورش آبی پاستیلی کمرنگ) let initialStrokeDiameter = baseDiameter + (minStrokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: initialStrokeDiameter, height: initialStrokeDiameter) dynamicStrokeLayer.position = center dynamicStrokeLayer.cornerRadius = initialStrokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = minStrokeWidth dynamicStrokeLayer.borderColor = NSColor(red: 0.65, green: 0.86, blue: 0.98, alpha: 0.90).cgColor // Soft Pastel Light Blue dynamicStrokeLayer.backgroundColor = NSColor.clear.cgColor dynamicStrokeLayer.shadowColor = NSColor(red: 0.58, green: 0.82, blue: 0.98, alpha: 0.85).cgColor dynamicStrokeLayer.shadowOffset = .zero dynamicStrokeLayer.shadowRadius = 4.0 dynamicStrokeLayer.shadowOpacity = 0.50 layer.addSublayer(dynamicStrokeLayer) } /// Updates audio response and live ping quality: /// Ping defines the Core Color: /// - Green (<450ms): Excellent low latency /// - Warm Yellow (450-750ms): Normal acceptable latency /// - Orange (750-1500ms): Degraded high latency /// - Red (>1500ms or Timeout/Error): Disconnected / severe lag func updateAudioLevel(_ level: Float, isLagging: Bool, pingQuality: PingQuality?) { CATransaction.begin() CATransaction.setDisableActions(true) let clamped = CGFloat(min(max(level, 0.0), 1.0)) let dynamicFactor = pow(clamped, 0.70) let strokeWidth = minStrokeWidth + (dynamicFactor * (maxStrokeWidth - minStrokeWidth)) let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth // Update Core Color & Text based on live Ping if let ping = pingQuality { if SonioxSettings.shared.targetLanguage.isEnabled { pingTextLayer.string = "\(ping.labelText) • \(SonioxSettings.shared.targetLanguage.rawValue.uppercased())" } else { pingTextLayer.string = ping.labelText } switch ping { case .good: // Vibrant Emerald Green (#2DD4BF / #22C55E) coreSolidLayer.backgroundColor = NSColor(red: 0.22, green: 0.82, blue: 0.44, alpha: 1.0).cgColor pingTextLayer.foregroundColor = NSColor(red: 0.04, green: 0.20, blue: 0.08, alpha: 0.95).cgColor case .medium: // Warm Yellow-Lime (#F0F233) coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.95, blue: 0.20, alpha: 1.0).cgColor pingTextLayer.foregroundColor = NSColor(red: 0.20, green: 0.18, blue: 0.02, alpha: 0.95).cgColor case .high: // Warning Vibrant Orange (#FB923C / #F97316) coreSolidLayer.backgroundColor = NSColor(red: 0.98, green: 0.55, blue: 0.14, alpha: 1.0).cgColor pingTextLayer.foregroundColor = NSColor(red: 0.24, green: 0.08, blue: 0.01, alpha: 0.95).cgColor case .disconnected: // Disconnected / Timeout Red (#EF4444) coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.22, blue: 0.22, alpha: 1.0).cgColor pingTextLayer.foregroundColor = NSColor.white.cgColor } } if isLagging { let amber = NSColor(red: 1.0, green: 0.68, blue: 0.15, alpha: 0.80 + (0.20 * dynamicFactor)).cgColor dynamicStrokeLayer.borderColor = amber dynamicStrokeLayer.shadowColor = amber } else { let pastelBlue = NSColor( red: 0.65 + (0.15 * dynamicFactor), green: 0.86 + (0.10 * dynamicFactor), blue: 0.98 + (0.02 * dynamicFactor), alpha: 0.85 + (0.15 * dynamicFactor) ).cgColor dynamicStrokeLayer.borderColor = pastelBlue dynamicStrokeLayer.shadowColor = NSColor(red: 0.58, green: 0.82, blue: 0.98, alpha: 0.85).cgColor } dynamicStrokeLayer.shadowRadius = 4.0 + (dynamicFactor * 24.0) dynamicStrokeLayer.shadowOpacity = Float(0.50 + (0.45 * dynamicFactor)) CATransaction.commit() } func showRetryState(reason: String = "RETRYING...") { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.20) pingTextLayer.string = reason pingTextLayer.fontSize = reason.count > 8 ? 9.0 : 10.0 pingTextLayer.foregroundColor = NSColor(red: 0.25, green: 0.15, blue: 0.02, alpha: 0.95).cgColor coreSolidLayer.backgroundColor = NSColor(red: 1.0, green: 0.75, blue: 0.20, alpha: 1.0).cgColor let amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 1.0).cgColor let strokeWidth: CGFloat = 5.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth dynamicStrokeLayer.borderColor = amber dynamicStrokeLayer.shadowColor = amber dynamicStrokeLayer.shadowRadius = 18 dynamicStrokeLayer.shadowOpacity = 0.90 CATransaction.commit() } func showTranscribingState() { CATransaction.begin() CATransaction.setDisableActions(true) pingTextLayer.string = "WAIT..." pingTextLayer.fontSize = 11.0 pingTextLayer.foregroundColor = NSColor.white.cgColor coreSolidLayer.backgroundColor = NSColor(red: 0.15, green: 0.20, blue: 0.30, alpha: 1.0).cgColor let strokeWidth: CGFloat = 3.5 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth dynamicStrokeLayer.borderColor = NSColor.white.cgColor dynamicStrokeLayer.shadowColor = NSColor.white.cgColor dynamicStrokeLayer.shadowRadius = 12 dynamicStrokeLayer.shadowOpacity = 0.70 CATransaction.commit() } /// User requested: When text is committed/inserted, the stroke turns vibrant Emerald Green! func showSuccessState() { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) pingTextLayer.string = "OK" pingTextLayer.fontSize = 11.0 pingTextLayer.foregroundColor = NSColor(red: 0.04, green: 0.20, blue: 0.08, alpha: 0.95).cgColor coreSolidLayer.backgroundColor = NSColor(red: 0.22, green: 0.82, blue: 0.44, alpha: 1.0).cgColor let emerald = NSColor(red: 0.18, green: 0.85, blue: 0.38, alpha: 1.0).cgColor // Vibrant Emerald Green let strokeWidth: CGFloat = 6.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth dynamicStrokeLayer.borderColor = emerald dynamicStrokeLayer.shadowColor = emerald dynamicStrokeLayer.shadowRadius = 24 dynamicStrokeLayer.shadowOpacity = 0.95 CATransaction.commit() } func showErrorState(message: String = "ERROR") { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) pingTextLayer.string = message pingTextLayer.fontSize = message.count > 8 ? 9.0 : 10.0 pingTextLayer.foregroundColor = NSColor.white.cgColor coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.22, blue: 0.22, alpha: 1.0).cgColor let darkRed = NSColor(red: 0.95, green: 0.15, blue: 0.15, alpha: 1.0).cgColor let strokeWidth: CGFloat = 5.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.cornerRadius = strokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = strokeWidth dynamicStrokeLayer.borderColor = darkRed dynamicStrokeLayer.shadowColor = darkRed dynamicStrokeLayer.shadowRadius = 20 dynamicStrokeLayer.shadowOpacity = 0.95 CATransaction.commit() } func reset() { CATransaction.begin() CATransaction.setDisableActions(true) let initialStrokeDiameter = baseDiameter + (minStrokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: initialStrokeDiameter, height: initialStrokeDiameter) dynamicStrokeLayer.cornerRadius = initialStrokeDiameter / 2.0 dynamicStrokeLayer.borderWidth = minStrokeWidth dynamicStrokeLayer.borderColor = NSColor(red: 0.65, green: 0.86, blue: 0.98, alpha: 0.90).cgColor dynamicStrokeLayer.shadowColor = NSColor(red: 0.58, green: 0.82, blue: 0.98, alpha: 0.85).cgColor dynamicStrokeLayer.shadowRadius = 4.0 dynamicStrokeLayer.shadowOpacity = 0.50 CATransaction.commit() } }