feat: add VU meter, segment counter and LED bar views
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible.
|
||||||
|
public struct SegmentCounterView: View {
|
||||||
|
private let text: String
|
||||||
|
|
||||||
|
public init(_ text: String) {
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentWidth: CGFloat {
|
||||||
|
text.reduce(0) { total, character in
|
||||||
|
total + (character.isNumber ? Segment.advance : Segment.colonWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Canvas { context, _ in
|
||||||
|
var x: CGFloat = 0
|
||||||
|
for character in text {
|
||||||
|
if character.isNumber {
|
||||||
|
var origin = context
|
||||||
|
origin.translateBy(x: x, y: 0)
|
||||||
|
let lit = SegmentGlyph.lit(for: character)
|
||||||
|
for id in SegmentID.allCases {
|
||||||
|
let polygon = SegmentGlyph.polygon(id)
|
||||||
|
var path = Path()
|
||||||
|
path.addLines(polygon)
|
||||||
|
path.closeSubpath()
|
||||||
|
origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit))
|
||||||
|
}
|
||||||
|
x += Segment.advance
|
||||||
|
} else {
|
||||||
|
let dot = Segment.thickness
|
||||||
|
let inset = (Segment.colonWidth - dot) / 2
|
||||||
|
let ys: [CGFloat] = character == ":"
|
||||||
|
? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64]
|
||||||
|
: [Segment.digitHeight - dot]
|
||||||
|
for y in ys {
|
||||||
|
context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber))
|
||||||
|
}
|
||||||
|
x += Segment.colonWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: contentWidth, height: Segment.digitHeight)
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.background(DisplayBackground())
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Tijd")
|
||||||
|
.accessibilityValue(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twelve-segment level bar: green, amber, then one red segment.
|
||||||
|
public struct LEDBarView: View {
|
||||||
|
private let fraction: Double
|
||||||
|
|
||||||
|
public init(fraction: Double) {
|
||||||
|
self.fraction = fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
let lit = LEDBar.litCount(forFraction: fraction)
|
||||||
|
HStack(spacing: MeterTokens.ledGap) {
|
||||||
|
ForEach(0..<MeterTokens.ledSegments, id: \.self) { index in
|
||||||
|
RoundedRectangle(cornerRadius: Radius.sharp, style: .circular)
|
||||||
|
.fill(color(for: index, lit: index < lit))
|
||||||
|
.frame(width: MeterTokens.ledSegmentWidth, height: MeterTokens.ledSegmentHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func color(for index: Int, lit: Bool) -> Color {
|
||||||
|
switch LEDBar.tone(forSegment: index) {
|
||||||
|
case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit
|
||||||
|
case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit
|
||||||
|
case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`.
|
||||||
|
/// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing.
|
||||||
|
public struct VUMeterView: View {
|
||||||
|
private let levelDbfs: Double
|
||||||
|
private let isActive: Bool
|
||||||
|
private let brand: String
|
||||||
|
|
||||||
|
@State private var needle: NeedleState
|
||||||
|
@State private var awake = false
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
private final class NeedleState {
|
||||||
|
var spring: NeedleSpring
|
||||||
|
var dampingUsed = Motion.needleDamping
|
||||||
|
var last: TimeInterval?
|
||||||
|
var peakUntil: TimeInterval = 0
|
||||||
|
|
||||||
|
init(position: Double) {
|
||||||
|
spring = NeedleSpring(position: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots.
|
||||||
|
public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) {
|
||||||
|
self.levelDbfs = levelDbfs
|
||||||
|
self.isActive = isActive
|
||||||
|
self.brand = brand
|
||||||
|
_needle = State(initialValue: NeedleState(position: initialFraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in
|
||||||
|
Canvas { context, size in
|
||||||
|
draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit)
|
||||||
|
.background(Palette.surfaceMeterFace)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)
|
||||||
|
.strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular))
|
||||||
|
.onChange(of: isActive) { _, active in
|
||||||
|
if active {
|
||||||
|
awake = true
|
||||||
|
} else {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
awake = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Niveau")
|
||||||
|
.accessibilityValue(spokenValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounded to steps of 3 VU so VoiceOver is not flooded with changes.
|
||||||
|
private var spokenValue: String {
|
||||||
|
guard isActive else { return "stil" }
|
||||||
|
let vu = (VUScale.vu(fromDbfs: levelDbfs) / 3).rounded() * 3
|
||||||
|
return "\(Int(vu)) VU"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight)
|
||||||
|
|
||||||
|
private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) {
|
||||||
|
let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight
|
||||||
|
let scale = min(size.width / width, size.height / height)
|
||||||
|
context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2)
|
||||||
|
context.scaleBy(x: scale, y: scale)
|
||||||
|
context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height)))
|
||||||
|
|
||||||
|
// physics
|
||||||
|
let damping = reduceMotion ? 1.0 : Motion.needleDamping
|
||||||
|
if needle.dampingUsed != damping {
|
||||||
|
needle.spring = NeedleSpring(damping: damping, position: needle.spring.position)
|
||||||
|
needle.dampingUsed = damping
|
||||||
|
}
|
||||||
|
let dt = needle.last.map { min(0.05, now - $0) } ?? 0
|
||||||
|
needle.last = now
|
||||||
|
let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0
|
||||||
|
let position = needle.spring.step(target: target, dt: dt)
|
||||||
|
let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position
|
||||||
|
if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds }
|
||||||
|
|
||||||
|
let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset)
|
||||||
|
func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint {
|
||||||
|
let angle = VUScale.angle(forFraction: fraction)
|
||||||
|
return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle)))
|
||||||
|
}
|
||||||
|
func arc(_ radius: CGFloat, from: Double, to: Double) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
let steps = 48
|
||||||
|
for step in 0...steps {
|
||||||
|
let p = point(radius, from + (to - from) * Double(step) / Double(steps))
|
||||||
|
step == 0 ? path.move(to: p) : path.addLine(to: p)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = MeterTokens.vuScaleRadius
|
||||||
|
let redFrom = VUScale.fraction(forVU: 0)
|
||||||
|
context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
|
||||||
|
context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth)
|
||||||
|
|
||||||
|
for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) {
|
||||||
|
let major = MeterTokens.vuTicks.contains(vu)
|
||||||
|
let fraction = VUScale.fraction(forVU: Double(vu))
|
||||||
|
let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter
|
||||||
|
var tick = Path()
|
||||||
|
tick.move(to: point(radius, fraction))
|
||||||
|
tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction))
|
||||||
|
context.stroke(tick, with: .color(color), lineWidth: Border.hairline)
|
||||||
|
if major {
|
||||||
|
let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color)
|
||||||
|
context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(
|
||||||
|
Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter),
|
||||||
|
at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center)
|
||||||
|
context.draw(
|
||||||
|
Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)),
|
||||||
|
at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading)
|
||||||
|
|
||||||
|
// peak lamp
|
||||||
|
let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6)
|
||||||
|
let lampRadius = Size.lampDiameter / 2
|
||||||
|
let peakLit = now < needle.peakUntil
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
|
||||||
|
with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit))
|
||||||
|
context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
|
||||||
|
with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
|
||||||
|
context.draw(
|
||||||
|
Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter),
|
||||||
|
at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center)
|
||||||
|
|
||||||
|
// needle
|
||||||
|
var needlePath = Path()
|
||||||
|
needlePath.move(to: pivot)
|
||||||
|
needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position))))
|
||||||
|
context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user