173 lines
5.8 KiB
Swift
Executable File
173 lines
5.8 KiB
Swift
Executable File
//
|
|
// HexCapsuleView.swift
|
|
// Hex
|
|
//
|
|
// Created by Kit Langton on 1/25/25.
|
|
|
|
import AppKit
|
|
import Inject
|
|
import Pow
|
|
import SwiftUI
|
|
|
|
struct TranscriptionIndicatorView: View {
|
|
@ObserveInjection var inject
|
|
|
|
enum Status {
|
|
case hidden
|
|
case optionKeyPressed
|
|
case recording
|
|
case transcribing
|
|
case prewarming
|
|
}
|
|
|
|
var status: Status
|
|
var meter: Meter
|
|
|
|
let transcribeBaseColor: Color = .blue
|
|
private var backgroundColor: Color {
|
|
switch status {
|
|
case .hidden: return Color.clear
|
|
case .optionKeyPressed: return Color.black
|
|
case .recording:
|
|
return mixedColor(mixedNSColor(.red, with: .black, by: 0.5), with: .red, by: meter.averagePower * 3)
|
|
case .transcribing: return mixedColor(.blue, with: .black, by: 0.5)
|
|
case .prewarming: return mixedColor(.blue, with: .black, by: 0.5)
|
|
}
|
|
}
|
|
|
|
private var strokeColor: Color {
|
|
switch status {
|
|
case .hidden: return Color.clear
|
|
case .optionKeyPressed: return Color.black
|
|
case .recording: return mixedColor(.red, with: .white, by: 0.1).opacity(0.6)
|
|
case .transcribing: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
|
case .prewarming: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
|
}
|
|
}
|
|
|
|
private func mixedColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> Color {
|
|
Color(nsColor: mixedNSColor(color, with: otherColor, by: fraction))
|
|
}
|
|
|
|
private func mixedNSColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> NSColor {
|
|
let clampedFraction = min(max(fraction, 0), 1)
|
|
return color.blended(withFraction: clampedFraction, of: otherColor) ?? color
|
|
}
|
|
|
|
private var innerShadowColor: Color {
|
|
switch status {
|
|
case .hidden: return Color.clear
|
|
case .optionKeyPressed: return Color.clear
|
|
case .recording: return Color.red
|
|
case .transcribing: return transcribeBaseColor
|
|
case .prewarming: return transcribeBaseColor
|
|
}
|
|
}
|
|
|
|
private let cornerRadius: CGFloat = 8
|
|
private let baseWidth: CGFloat = 16
|
|
private let expandedWidth: CGFloat = 56
|
|
|
|
var isHidden: Bool {
|
|
status == .hidden
|
|
}
|
|
|
|
@State var transcribeEffect = 0
|
|
|
|
var body: some View {
|
|
let averagePower = min(1, meter.averagePower * 3)
|
|
let peakPower = min(1, meter.peakPower * 3)
|
|
ZStack {
|
|
Capsule()
|
|
.fill(backgroundColor.shadow(.inner(color: innerShadowColor, radius: 4)))
|
|
.overlay {
|
|
Capsule()
|
|
.stroke(strokeColor, lineWidth: 1)
|
|
.blendMode(.screen)
|
|
}
|
|
.overlay(alignment: .center) {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(Color.red.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 1) : 0))
|
|
.blur(radius: 2)
|
|
.blendMode(.screen)
|
|
.padding(6)
|
|
}
|
|
.overlay(alignment: .center) {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(Color.white.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 0.5) : 0))
|
|
.blur(radius: 1)
|
|
.blendMode(.screen)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(7)
|
|
}
|
|
.overlay(alignment: .center) {
|
|
GeometryReader { proxy in
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(Color.red.opacity(status == .recording ? (peakPower < 0.1 ? (peakPower / 0.1) * 0.5 : 0.5) : 0))
|
|
.frame(width: max(proxy.size.width * (peakPower + 0.6), 0), height: proxy.size.height, alignment: .center)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.blur(radius: 4)
|
|
.blendMode(.screen)
|
|
}.padding(6)
|
|
}
|
|
.cornerRadius(cornerRadius)
|
|
.shadow(
|
|
color: status == .recording ? .red.opacity(averagePower) : .red.opacity(0),
|
|
radius: 4
|
|
)
|
|
.shadow(
|
|
color: status == .recording ? .red.opacity(averagePower * 0.5) : .red.opacity(0),
|
|
radius: 8
|
|
)
|
|
.animation(.interactiveSpring(), value: meter)
|
|
.frame(
|
|
width: status == .recording ? expandedWidth : baseWidth,
|
|
height: baseWidth
|
|
)
|
|
.opacity(status == .hidden ? 0 : 1)
|
|
.scaleEffect(status == .hidden ? 0.0 : 1)
|
|
.blur(radius: status == .hidden ? 4 : 0)
|
|
.animation(.bouncy(duration: 0.3), value: status)
|
|
.changeEffect(.glow(color: .red.opacity(0.5), radius: 8), value: status)
|
|
.changeEffect(.shine(angle: .degrees(0), duration: 0.6), value: transcribeEffect)
|
|
.compositingGroup()
|
|
.task(id: status == .transcribing) {
|
|
while status == .transcribing, !Task.isCancelled {
|
|
transcribeEffect += 1
|
|
try? await Task.sleep(for: .seconds(0.25))
|
|
}
|
|
}
|
|
|
|
// Show tooltip when prewarming
|
|
if status == .prewarming {
|
|
VStack(spacing: 4) {
|
|
Text("Model prewarming...")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.black.opacity(0.8))
|
|
)
|
|
}
|
|
.offset(y: -24)
|
|
.transition(.opacity)
|
|
.zIndex(2)
|
|
}
|
|
}
|
|
.enableInjection()
|
|
}
|
|
}
|
|
|
|
#Preview("HEX") {
|
|
VStack(spacing: 8) {
|
|
TranscriptionIndicatorView(status: .hidden, meter: .init(averagePower: 0, peakPower: 0))
|
|
TranscriptionIndicatorView(status: .optionKeyPressed, meter: .init(averagePower: 0, peakPower: 0))
|
|
TranscriptionIndicatorView(status: .recording, meter: .init(averagePower: 0.5, peakPower: 0.5))
|
|
TranscriptionIndicatorView(status: .transcribing, meter: .init(averagePower: 0, peakPower: 0))
|
|
TranscriptionIndicatorView(status: .prewarming, meter: .init(averagePower: 0, peakPower: 0))
|
|
}
|
|
.padding(40)
|
|
}
|