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.
372 lines
16 KiB
372 lines
16 KiB
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()
|
|
}
|
|
}
|