You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
10 KiB
298 lines
10 KiB
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()
|
|
}
|
|
}
|