/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import AppKit import Cocoa import SwiftUI class AudioSpectrum: NSView { private var barLayers: [CAShapeLayer] = [] private var isPlaying: Bool = true private var animationTimer: Timer? override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true setupBars() } deinit { animationTimer?.invalidate() } required init?(coder: NSCoder) { super.init(coder: coder) wantsLayer = true setupBars() } private func setupBars() { let barWidth: CGFloat = 2 let barCount = 4 let spacing: CGFloat = barWidth let totalWidth = CGFloat(barCount) * (barWidth + spacing) let totalHeight: CGFloat = 14 frame.size = CGSize(width: totalWidth, height: totalHeight) for i in 0 ..< barCount { let xPosition = CGFloat(i) * (barWidth + spacing) let barLayer = CAShapeLayer() barLayer.frame = CGRect(x: xPosition, y: 0, width: barWidth, height: totalHeight) barLayer.position = CGPoint(x: xPosition + barWidth / 2, y: totalHeight / 2) barLayer.fillColor = NSColor.white.cgColor let path = NSBezierPath(roundedRect: CGRect(x: 0, y: 0, width: barWidth, height: totalHeight), xRadius: barWidth / 2, yRadius: barWidth / 2) barLayer.path = path.cgPath barLayers.append(barLayer) layer?.addSublayer(barLayer) } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { stopAnimating() } else if isPlaying { startAnimating() } } private func startAnimating() { guard animationTimer == nil else { return } animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in self?.updateBars() } } private func stopAnimating() { animationTimer?.invalidate() animationTimer = nil resetBars() } private func updateBars() { for barLayer in barLayers { let animation = CABasicAnimation(keyPath: "transform.scale.y") animation.fromValue = barLayer.presentation()?.value(forKeyPath: "transform.scale.y") ?? 0.35 animation.toValue = CGFloat.random(in: 0.35 ... 1.0) animation.duration = 0.3 animation.autoreverses = true animation.fillMode = .forwards animation.isRemovedOnCompletion = false if #available(macOS 13.0, *) { animation.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 24, preferred: 24) } barLayer.add(animation, forKey: "scaleY") } } private func resetBars() { for barLayer in barLayers { barLayer.removeAllAnimations() barLayer.transform = CATransform3DMakeScale(1, 0.35, 1) } } func setPlaying(_ playing: Bool) { isPlaying = playing if isPlaying { startAnimating() } else { stopAnimating() } } } struct AudioSpectrumView: NSViewRepresentable { @Binding var isPlaying: Bool func makeNSView(context: Context) -> AudioSpectrum { let spectrum = AudioSpectrum() spectrum.setPlaying(isPlaying) return spectrum } func updateNSView(_ nsView: AudioSpectrum, context: Context) { nsView.setPlaying(isPlaying) } static func dismantleNSView(_ nsView: AudioSpectrum, coordinator: ()) { nsView.setPlaying(false) } } #Preview { AudioSpectrumView(isPlaying: .constant(true)) .frame(width: 16, height: 20) .padding() }