From d73f7660c356dcc041ffddf0075f4b5d27dbfda7 Mon Sep 17 00:00:00 2001 From: idevlab Date: Thu, 30 Jul 2026 00:58:44 +0800 Subject: [PATCH] Refine recording overlay controls --- Sources/App/OpenTypeApp.swift | 2 +- Sources/App/VoicePipeline+Status.swift | 19 +- Sources/App/VoicePipeline.swift | 32 ++- .../Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + Sources/UI/OverlayActionButton.swift | 99 +++++++ Sources/UI/OverlayPanel.swift | 41 ++- Sources/UI/OverlayPanelContent.swift | 271 ++++++++---------- Sources/UI/WaveformView.swift | 65 +++-- Tests/OpenTypeTests/OverlayLayoutTests.swift | 59 +++- 10 files changed, 404 insertions(+), 188 deletions(-) create mode 100644 Sources/UI/OverlayActionButton.swift diff --git a/Sources/App/OpenTypeApp.swift b/Sources/App/OpenTypeApp.swift index a41c8a3a..ed38be69 100644 --- a/Sources/App/OpenTypeApp.swift +++ b/Sources/App/OpenTypeApp.swift @@ -144,7 +144,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { let mode: VoiceInputMode = action == .translation ? .translation(AppSettings.shared.translationTargetLanguage) : .dictation - Task { await pipeline?.start(mode: mode) } + Task { await pipeline?.start(mode: mode, targetApp: previousApp) } } private func stopRecording() { diff --git a/Sources/App/VoicePipeline+Status.swift b/Sources/App/VoicePipeline+Status.swift index 6f708ab4..8e42f1f0 100644 --- a/Sources/App/VoicePipeline+Status.swift +++ b/Sources/App/VoicePipeline+Status.swift @@ -3,6 +3,20 @@ import Foundation @MainActor extension VoicePipeline { + func showOverlay() { + overlay.show( + appState: appState, + onCancel: { [weak self] in + self?.cancel() + }, + onConfirm: { [weak self] in + Task { @MainActor in + await self?.stop() + } + } + ) + } + func showNoSpeechDetected(reason: String) { Log.info("[VoicePipeline] no speech detected: \(reason)") cancelScreenContextCapture() @@ -12,6 +26,7 @@ extension VoicePipeline { } func resetToIdle() { + recordingTargetApp = nil appState.phase = .idle appState.statusMessage = L("status.ready") overlay.hide() @@ -35,7 +50,7 @@ extension VoicePipeline { let saved = appState.statusMessage appState.statusMessage = L("pipeline.busy") soundPlayer.playStop() - overlay.show(appState: appState) + showOverlay() Task { @MainActor in try? await Task.sleep(nanoseconds: 2_000_000_000) if appState.statusMessage == L("pipeline.busy") { @@ -49,7 +64,7 @@ extension VoicePipeline { appState.statusMessage = message appState.resetDownloadProgress() soundPlayer.playStop() - overlay.show(appState: appState) + showOverlay() hideOverlayAfterDelay() } diff --git a/Sources/App/VoicePipeline.swift b/Sources/App/VoicePipeline.swift index ad63493b..9b29abb8 100644 --- a/Sources/App/VoicePipeline.swift +++ b/Sources/App/VoicePipeline.swift @@ -19,6 +19,7 @@ final class VoicePipeline { var processingTask: Task? var replacementTask: Task? var hideOverlayTask: Task? + var recordingTargetApp: NSRunningApplication? var currentEngine: (any SpeechEngine)? { switch appState.settings.speechEngine { @@ -64,7 +65,10 @@ final class VoicePipeline { // MARK: - Recording - func start(mode: VoiceInputMode = .dictation) async { + func start( + mode: VoiceInputMode = .dictation, + targetApp: NSRunningApplication? = nil + ) async { if appState.isBusy { Log.info("[VoicePipeline] start: busy (\(appState.phase)), ignoring") showBusyHint() @@ -98,6 +102,7 @@ final class VoicePipeline { appState.statusMessage = mode.isTranslation ? L("pipeline.recording_translation") : L("pipeline.recording") + recordingTargetApp = targetApp if mode.isTranslation { cancelScreenContextCapture() @@ -106,7 +111,7 @@ final class VoicePipeline { } soundPlayer.playStart() - overlay.show(appState: appState) + showOverlay() let micID = appState.settings.microphoneID let language = appState.settings.inputLanguage.whisperCode @@ -137,6 +142,8 @@ final class VoicePipeline { ) guard micStarted else { currentEngine?.cancelListening() + cancelScreenContextCapture() + recordingTargetApp = nil appState.phase = .error(L("pipeline.mic_failed_permissions")) appState.statusMessage = L("pipeline.mic_unavailable") overlay.hide() @@ -150,6 +157,8 @@ final class VoicePipeline { return } + let resolvedTargetApp = targetApp ?? recordingTargetApp + recordingTargetApp = nil soundPlayer.playStop() audioCapture.stop() @@ -170,11 +179,28 @@ final class VoicePipeline { language: language, settings: settings, inputMode: inputMode, - targetApp: targetApp + targetApp: resolvedTargetApp ) } } + func cancel() { + guard appState.isRecording else { + Log.info("[VoicePipeline] cancel: not recording (\(appState.phase)), ignoring") + return + } + + Log.info("[VoicePipeline] recording cancelled by user") + clearInFlightWork() + currentEngine?.cancelListening() + audioCapture.stop() + audioCapture.cleanupLastRecording() + recordingTargetApp = nil + soundPlayer.playStop() + appState.reset() + overlay.hide() + } + private func clearInFlightWork() { processingTask?.cancel() processingTask = nil diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 4e222f29..bd6d1662 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -40,6 +40,8 @@ "pipeline.model_load_failed_network" = "The model could not be loaded. Check your network and try again."; "pipeline.recording" = "Recording…"; "pipeline.recording_translation" = "Recording for translation…"; +"overlay.cancel_recording" = "Cancel recording"; +"overlay.finish_recording" = "Finish recording"; "pipeline.transcribing" = "Transcribing…"; "pipeline.formatting" = "Formatting…"; "pipeline.translating" = "Translating…"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index c440d508..6d3ffe59 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -40,6 +40,8 @@ "pipeline.model_load_failed_network" = "模型未能加载,请检查网络后重试"; "pipeline.recording" = "录音中…"; "pipeline.recording_translation" = "正在录音并准备翻译…"; +"overlay.cancel_recording" = "取消录音"; +"overlay.finish_recording" = "结束录音"; "pipeline.transcribing" = "识别中…"; "pipeline.formatting" = "整理中…"; "pipeline.translating" = "翻译中…"; diff --git a/Sources/UI/OverlayActionButton.swift b/Sources/UI/OverlayActionButton.swift new file mode 100644 index 00000000..67ea914f --- /dev/null +++ b/Sources/UI/OverlayActionButton.swift @@ -0,0 +1,99 @@ +import SwiftUI + +enum OverlayControlMetrics { + static let actionButtonSize: CGFloat = 26 + static let recordingControlsWidth: CGFloat = 134 +} + +enum OverlayActionKind { + case cancel + case confirm + + var symbolName: String { + switch self { + case .cancel: return "xmark" + case .confirm: return "checkmark" + } + } + + var accessibilityLabel: String { + switch self { + case .cancel: return L("overlay.cancel_recording") + case .confirm: return L("overlay.finish_recording") + } + } + + var keyboardShortcut: KeyboardShortcut { + switch self { + case .cancel: return .cancelAction + case .confirm: return .defaultAction + } + } +} + +struct OverlayActionButton: View { + let kind: OverlayActionKind + let action: () -> Void + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: kind.symbolName) + } + .buttonStyle(OverlayActionButtonStyle(kind: kind, isHovering: isHovering)) + .keyboardShortcut(kind.keyboardShortcut) + .accessibilityLabel(kind.accessibilityLabel) + .help(kind.accessibilityLabel) + .onHover { isHovering = $0 } + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: isHovering) + } +} + +private struct OverlayActionButtonStyle: ButtonStyle { + let kind: OverlayActionKind + let isHovering: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(foregroundStyle) + .frame( + width: OverlayControlMetrics.actionButtonSize, + height: OverlayControlMetrics.actionButtonSize + ) + .background { + Circle() + .fill(backgroundStyle(isPressed: configuration.isPressed)) + } + .overlay { + Circle() + .strokeBorder(borderStyle, lineWidth: 0.5) + } + .contentShape(Circle()) + } + + private var foregroundStyle: Color { + switch kind { + case .cancel: return .white.opacity(0.72) + case .confirm: return .black.opacity(0.82) + } + } + + private func backgroundStyle(isPressed: Bool) -> Color { + switch kind { + case .cancel: + return .white.opacity(isPressed ? 0.18 : isHovering ? 0.13 : 0.07) + case .confirm: + return .white.opacity(isPressed ? 0.76 : isHovering ? 1 : 0.9) + } + } + + private var borderStyle: Color { + switch kind { + case .cancel: return .white.opacity(0.08) + case .confirm: return .white.opacity(0.42) + } + } +} diff --git a/Sources/UI/OverlayPanel.swift b/Sources/UI/OverlayPanel.swift index 3a27b411..01b01e45 100644 --- a/Sources/UI/OverlayPanel.swift +++ b/Sources/UI/OverlayPanel.swift @@ -6,7 +6,11 @@ final class OverlayPanel { private var hostingView: NSHostingView? @MainActor - func show(appState: AppState) { + func show( + appState: AppState, + onCancel: @escaping () -> Void, + onConfirm: @escaping () -> Void + ) { let initialLayout = OverlayLayout(appState: appState) if window == nil { @@ -21,14 +25,19 @@ final class OverlayPanel { panel.backgroundColor = .clear panel.hasShadow = false panel.hidesOnDeactivate = false - panel.ignoresMouseEvents = true + panel.ignoresMouseEvents = !initialLayout.isInteractive + panel.becomesKeyOnlyIfNeeded = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] - let view = OverlayContentView { [weak self] layout in - Task { @MainActor in - self?.apply(layout: layout, animated: true) - } - } + let view = OverlayContentView( + onLayoutChange: { [weak self] layout in + Task { @MainActor in + self?.apply(layout: layout, animated: true) + } + }, + onCancel: onCancel, + onConfirm: onConfirm + ) .environmentObject(appState) let hostingView = NSHostingView(rootView: AnyView(view)) @@ -62,6 +71,7 @@ final class OverlayPanel { hostingView.frame = NSRect(origin: .zero, size: layout.panelSize) hostingView.layer?.cornerRadius = layout.outerCornerRadius hostingView.layer?.cornerCurve = .continuous + window.ignoresMouseEvents = !layout.isInteractive window.setFrame(frame, display: true, animate: animated) } @@ -69,8 +79,19 @@ final class OverlayPanel { private func frame(for size: CGSize, window: NSWindow) -> NSRect { let screen = window.screen ?? NSScreen.main let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: size.width, height: size.height) - let x = visibleFrame.midX - size.width / 2 - let y = visibleFrame.maxY - size.height - 28 - return NSRect(origin: NSPoint(x: x, y: y), size: size) + return OverlayPanelPlacement.frame(for: size, in: visibleFrame) + } +} + +enum OverlayPanelPlacement { + static let bottomMargin: CGFloat = 24 + + static func frame(for size: CGSize, in visibleFrame: CGRect) -> CGRect { + CGRect( + x: visibleFrame.midX - size.width / 2, + y: visibleFrame.minY + bottomMargin, + width: size.width, + height: size.height + ) } } diff --git a/Sources/UI/OverlayPanelContent.swift b/Sources/UI/OverlayPanelContent.swift index 28303cae..6ceddafb 100644 --- a/Sources/UI/OverlayPanelContent.swift +++ b/Sources/UI/OverlayPanelContent.swift @@ -4,7 +4,7 @@ struct OverlayLayout: Equatable { let width: CGFloat let height: CGFloat let outerCornerRadius: CGFloat - let innerCornerRadius: CGFloat + let isInteractive: Bool let horizontalPadding: CGFloat let topPadding: CGFloat let bottomPadding: CGFloat @@ -17,62 +17,62 @@ struct OverlayLayout: Equatable { @MainActor init(appState: AppState) { let hasPreview = appState.phase == .recording && !appState.rawTranscription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + isInteractive = appState.isRecording switch appState.phase { case .recording where hasPreview: - width = 396 - height = 148 - outerCornerRadius = 28 - innerCornerRadius = 20 - horizontalPadding = 18 - topPadding = 18 - bottomPadding = 16 - stackSpacing = 12 + width = 304 + height = 80 + outerCornerRadius = 22 + horizontalPadding = 14 + topPadding = 8 + bottomPadding = 8 + stackSpacing = 6 case .recording: - let compactHeight: CGFloat = 64 - width = 324 - height = compactHeight - outerCornerRadius = compactHeight / 2 - innerCornerRadius = 18 - horizontalPadding = 18 - topPadding = 16 - bottomPadding = 16 - stackSpacing = 10 + width = 148 + height = 40 + outerCornerRadius = 20 + horizontalPadding = 7 + topPadding = 7 + bottomPadding = 7 + stackSpacing = 6 case .transcribing, .processing, .inserting: - width = 348 - height = 76 - outerCornerRadius = 24 - innerCornerRadius = 18 - horizontalPadding = 18 - topPadding = 16 - bottomPadding = 14 - stackSpacing = 10 + width = 216 + height = 48 + outerCornerRadius = 18 + horizontalPadding = 12 + topPadding = 10 + bottomPadding = 9 + stackSpacing = 6 case .error: - width = 372 - height = 76 - outerCornerRadius = 24 - innerCornerRadius = 18 - horizontalPadding = 18 - topPadding = 16 - bottomPadding = 14 - stackSpacing = 10 + width = 288 + height = 56 + outerCornerRadius = 18 + horizontalPadding = 12 + topPadding = 8 + bottomPadding = 8 + stackSpacing = 6 default: - width = 324 - height = 60 - outerCornerRadius = 24 - innerCornerRadius = 18 - horizontalPadding = 18 - topPadding = 16 - bottomPadding = 16 - stackSpacing = 10 + width = 192 + height = 40 + outerCornerRadius = 20 + horizontalPadding = 12 + topPadding = 10 + bottomPadding = 10 + stackSpacing = 6 } } } struct OverlayContentView: View { @EnvironmentObject var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var colorSchemeContrast let onLayoutChange: (OverlayLayout) -> Void + let onCancel: () -> Void + let onConfirm: () -> Void @State private var fakeProgress: Double = 0 @State private var progressTimer: Timer? @@ -103,11 +103,16 @@ struct OverlayContentView: View { var body: some View { VStack(spacing: layout.stackSpacing) { - statusRow - .padding(.top, layout.topPadding) + if layout.isInteractive { + recordingControls + .padding(.top, layout.topPadding) + } else { + statusRow + .padding(.top, layout.topPadding) + } if let livePreview { - previewCard(text: livePreview) + livePreviewText(livePreview) } if showsProgress { @@ -117,27 +122,9 @@ struct OverlayContentView: View { .padding(.horizontal, layout.horizontalPadding) .padding(.bottom, layout.bottomPadding) .frame(width: layout.width, height: layout.height, alignment: .center) - .background( - RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous) - .fill( - LinearGradient( - colors: [ - Color.black.opacity(0.34), - Color.black.opacity(0.18), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - ) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous) - .stroke(.white.opacity(0.16), lineWidth: 0.7) - ) + .background(panelBackground) .clipShape(RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous)) .compositingGroup() - .shadow(color: .black.opacity(0.18), radius: 18, x: 0, y: 12) .onAppear { handlePhaseChange(appState.phase) onLayoutChange(layout) @@ -148,81 +135,89 @@ struct OverlayContentView: View { .onChange(of: layout) { _, newLayout in onLayoutChange(newLayout) } - .animation(.easeInOut(duration: 0.24), value: layout) - .animation(.easeInOut(duration: 0.28), value: fakeProgress) + .onDisappear { + progressTimer?.invalidate() + progressTimer = nil + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.18), value: layout) + .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: fakeProgress) } private var statusRow: some View { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(statusAccent.opacity(0.16)) - .frame(width: 28, height: 28) - statusIcon - .font(.system(size: 14, weight: .semibold)) - } + HStack(spacing: 8) { + statusIcon + .font(.caption.weight(.medium)) + .frame(width: 16, height: 16) + .accessibilityHidden(true) Text(appState.statusMessage) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.96)) + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(isError ? 0.94 : 0.88)) .lineLimit(isError ? 2 : 1) .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + } + } + + private var recordingControls: some View { + HStack(spacing: 0) { + OverlayActionButton(kind: .cancel, action: onCancel) + Spacer(minLength: 0) - if appState.isRecording { - WaveformView(level: appState.audioLevel) - .frame(width: 38, height: 18) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - Capsule() - .fill(.white.opacity(0.08)) - ) - } + WaveformView(level: appState.audioLevel) + .frame(width: 42, height: 14) + .accessibilityHidden(true) + + Spacer(minLength: 0) + + OverlayActionButton(kind: .confirm, action: onConfirm) } + .frame(width: OverlayControlMetrics.recordingControlsWidth) } - private func previewCard(text: String) -> some View { + private func livePreviewText(_ text: String) -> some View { Text(text) - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.94)) - .lineLimit(3) + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: layout.innerCornerRadius, style: .continuous) - .fill(.white.opacity(0.09)) - .overlay( - RoundedRectangle(cornerRadius: layout.innerCornerRadius, style: .continuous) - .stroke(.white.opacity(0.12), lineWidth: 0.6) - ) - ) } private var progressBar: some View { GeometryReader { geo in Capsule() - .fill(.white.opacity(0.12)) - .frame(height: 5) + .fill(.white.opacity(0.1)) + .frame(height: 2) .overlay(alignment: .leading) { Capsule() - .fill( - LinearGradient( - colors: [ - Color(red: 1.0, green: 0.76, blue: 0.28), - Color(red: 1.0, green: 0.47, blue: 0.18), - ], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geo.size.width * fakeProgress, height: 5) + .fill(.white.opacity(0.66)) + .frame(width: geo.size.width * fakeProgress, height: 2) } } - .frame(height: 5) + .frame(height: 2) + .padding(.leading, 24) + .accessibilityHidden(true) + } + + private var panelBackground: some View { + let shape = RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous) + return ZStack { + if reduceTransparency { + shape.fill(Color.black.opacity(0.92)) + } else { + shape.fill(.ultraThinMaterial) + shape.fill(Color.black.opacity(0.22)) + } + } + .overlay { + shape.strokeBorder( + .white.opacity(colorSchemeContrast == .increased ? 0.28 : 0.12), + lineWidth: colorSchemeContrast == .increased ? 1 : 0.5 + ) + } } private func handlePhaseChange(_ phase: AppPhase) { @@ -260,46 +255,32 @@ struct OverlayContentView: View { switch appState.phase { case .recording: Image(systemName: "mic.fill") - .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.34)) + .foregroundStyle(Color(nsColor: .systemRed).opacity(0.82)) case .transcribing: Image(systemName: "waveform.badge.magnifyingglass") - .foregroundStyle(Color(red: 1.0, green: 0.78, blue: 0.28)) - .symbolEffect(.pulse.byLayer) - case .processing, .inserting: - Image(systemName: "brain") - .foregroundStyle(Color(red: 1.0, green: 0.6, blue: 0.25)) + .foregroundStyle(.white.opacity(0.76)) + .symbolEffect(.pulse.byLayer, isActive: !reduceMotion) + case .processing: + Image(systemName: "textformat") + .foregroundStyle(.white.opacity(0.76)) + case .inserting: + Image(systemName: "text.cursor") + .foregroundStyle(.white.opacity(0.76)) case .loadingModel: Image(systemName: "shippingbox.fill") - .foregroundStyle(.blue) + .foregroundStyle(.white.opacity(0.76)) case .downloading: - Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.blue) + Image(systemName: "arrow.down") + .foregroundStyle(.white.opacity(0.76)) case .done: - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) + Image(systemName: "checkmark") + .foregroundStyle(.white.opacity(0.84)) case .error: - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + Image(systemName: "exclamationmark") + .foregroundStyle(Color(nsColor: .systemRed).opacity(0.88)) default: Image(systemName: "mic") - .foregroundStyle(.white.opacity(0.72)) - } - } - - private var statusAccent: Color { - switch appState.phase { - case .recording: - return Color(red: 1.0, green: 0.45, blue: 0.34) - case .transcribing: - return Color(red: 1.0, green: 0.78, blue: 0.28) - case .processing, .inserting: - return Color(red: 1.0, green: 0.6, blue: 0.25) - case .done: - return .green - case .error: - return .red - default: - return .white + .foregroundStyle(.white.opacity(0.7)) } } } diff --git a/Sources/UI/WaveformView.swift b/Sources/UI/WaveformView.swift index 1ddd5003..d037fa34 100644 --- a/Sources/UI/WaveformView.swift +++ b/Sources/UI/WaveformView.swift @@ -3,34 +3,18 @@ import SwiftUI struct WaveformView: View { let level: Float - private let barCount = 5 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let barCount = 7 @State private var smoothLevel: Float = 0 var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60)) { timeline in - Canvas { context, size in - let barWidth: CGFloat = 2.5 - let gap: CGFloat = 2 - let totalW = CGFloat(barCount) * barWidth + CGFloat(barCount - 1) * gap - let originX = (size.width - totalW) / 2 - let time = timeline.date.timeIntervalSinceReferenceDate - - // Smooth the level to avoid jitter - let target = CGFloat(max(level, 0.05)) - let smooth = target * 0.4 + CGFloat(smoothLevel) * 0.6 - - for i in 0.. some View { + Canvas { context, size in + let barWidth: CGFloat = 1.5 + let gap: CGFloat = 2.25 + let totalWidth = CGFloat(barCount) * barWidth + CGFloat(barCount - 1) * gap + let originX = (size.width - totalWidth) / 2 + let target = max(CGFloat(level), 0.08) + let energy = target * 0.45 + CGFloat(smoothLevel) * 0.55 + + for index in 0..