// // AudioButton.swift // Crush // // Created by Leon on 2025/8/1. // import UIKit import SnapKit import Lottie enum AudioButtonEnumSize: Int { /// height: 48 case large /// height: 32 case medium /// height: 32 case small /// 20x20 case square20 case square24 } // Define AudioButtonStyle enum enum AudioButtonStyle: Int { // Clear bg. used in AI Role create process, select voice case clearBg /// White triangle + semi-transparent black circle background case videoPlay /// 白色三角 + 主体背景色 case voiceThemePlay } class AudioButton: CLButton { // MARK: - Properties var bgImageView: UIImageView! var leftImageView: UIImageView! var voiceAnimation: LottieAnimationView! private var indicator: IndicatorView! private var buttonSize: AudioButtonEnumSize = .medium private var path: String = "" private var iconSize: CGSize = CGSize(width: 16, height: 16) // MARK: - States var isDisplay: Bool = true { didSet { isHidden = !isDisplay } } var indicatorAnimated: Bool { return indicator.isAnimating } var audioLottieAnimated: Bool{ return voiceAnimation.isAnimationPlaying } // MARK: - Initialization static func buttonWithSize(_ size: AudioButtonEnumSize) -> AudioButton { return AudioButton(size: size) } static func buttonWithStyle(_ style: AudioButtonStyle) -> AudioButton { return AudioButton(style: style) } convenience init(size: AudioButtonEnumSize) { self.init(frame: .zero) self.buttonSize = size initialViews() } convenience init(style: AudioButtonStyle, size:AudioButtonEnumSize = .square20) { self.init(frame: .zero) switch style { case .videoPlay: initialCirclePlayStyle() case .clearBg: initialClearBgPlayStyle() case .voiceThemePlay: initialVoiceThemePlay() } } override init(frame: CGRect) { super.init(frame: frame) // Default initialization, but we discourage direct init // assertionFailure("Use buttonWithSize or buttonWithStyle instead!") } required init?(coder: NSCoder) { super.init(coder: coder) self.buttonSize = .medium initialViews() } // MARK: - 🚩Style 1: Traditional Audio Button private func initialViews() { isDisplay = true var width: CGFloat = 88 var height: CGFloat = 48 var cornerRadius: CGFloat = 24 var imageEdgeInsets: UIEdgeInsets = .zero switch buttonSize { case .large: height = 48 cornerRadius = 24 imageEdgeInsets = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) iconSize = CGSize(width: 20, height: 20) case .small: height = 32 width = 52 cornerRadius = 16 imageEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) iconSize = CGSize(width: 16, height: 16) case .medium: height = 32 cornerRadius = 16 imageEdgeInsets = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) iconSize = CGSize(width: 16, height: 16) case .square20: height = 20 cornerRadius = 0 imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) iconSize = CGSize(width: height, height: height) case .square24: height = 24 cornerRadius = 0 imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) iconSize = CGSize(width: height, height: height) } snp.makeConstraints { make in make.height.equalTo(height) } layer.cornerRadius = cornerRadius clipsToBounds = true self.imageEdgeInsets = imageEdgeInsets bgImageView = UIImageView() bgImageView.clipsToBounds = true bgImageView.image = UIImage.withColor(color: .purple, size: CGSize(width: width, height: 24)) addSubview(bgImageView) bgImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } leftImageView = UIImageView() leftImageView.image = iconOfWhitePlay() addSubview(leftImageView) leftImageView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 14, height: 14)) } let animation = LottieAnimation.named("voice_white") voiceAnimation = LottieAnimationView(animation: animation) // voiceAnimation = LottieAnimationView.animationNamed("voice_white.json") voiceAnimation.isUserInteractionEnabled = false voiceAnimation.loopMode = .loop voiceAnimation.animationSpeed = 1.4 voiceAnimation.contentMode = .scaleAspectFit voiceAnimation.isHidden = true voiceAnimation.tintColor = .white addSubview(voiceAnimation) voiceAnimation.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 14, height: 14)) } indicator = IndicatorView(color: .white) indicator.size = CGSize(width: 12, height: 12) indicator.isHidden = true addSubview(indicator) indicator.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 12, height: 12)) } } // MARK: - Style(Custom) 2: Circle Play Style private func initialCirclePlayStyle() { let width: CGFloat = 32 iconSize = CGSize(width: 16, height: 16) snp.makeConstraints { make in make.height.equalTo(32) } layer.cornerRadius = 16 clipsToBounds = true bgImageView = UIImageView() bgImageView.clipsToBounds = true bgImageView.image = UIImage.withColor(color: UIColor.black.withAlphaComponent(0.5), size: CGSize(width: width, height: width)) addSubview(bgImageView) bgImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } leftImageView = UIImageView() leftImageView.image = iconOfWhitePlay() addSubview(leftImageView) leftImageView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } let animation = LottieAnimation.named("voice_white") voiceAnimation = LottieAnimationView(animation: animation) //voiceAnimation = EPLottieAnimationView.animationNamed("voice_white.json") voiceAnimation.isUserInteractionEnabled = false voiceAnimation.loopMode = .loop voiceAnimation.animationSpeed = 1.4 voiceAnimation.contentMode = .scaleAspectFit voiceAnimation.isHidden = true voiceAnimation.tintColor = .white addSubview(voiceAnimation) voiceAnimation.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } indicator = IndicatorView(color: .white) indicator.size = CGSize(width: 16, height: 16) indicator.isHidden = true addSubview(indicator) indicator.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } } // MARK: - Style(Custom) 3: Clear bg private func initialClearBgPlayStyle() { iconSize = CGSize(width: 20, height: 20) touchAreaInsets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) snp.makeConstraints { make in make.width.equalTo(20) make.height.equalTo(20) } layer.cornerRadius = 0 clipsToBounds = true leftImageView = UIImageView() leftImageView.image = iconOfWhitePlay() addSubview(leftImageView) leftImageView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } let animation = LottieAnimation.named("voice_white")// voiceAnimation = LottieAnimationView(animation: animation) voiceAnimation.isUserInteractionEnabled = false voiceAnimation.loopMode = .loop voiceAnimation.animationSpeed = 1.4 voiceAnimation.contentMode = .scaleAspectFit voiceAnimation.isHidden = true voiceAnimation.tintColor = .black addSubview(voiceAnimation) voiceAnimation.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } indicator = IndicatorView(color: .white) // .withAlphaComponent(0.5) indicator.size = CGSize(width: 16, height: 16) indicator.isHidden = true addSubview(indicator) indicator.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 16, height: 16)) } } private func initialVoiceThemePlay(){ iconSize = CGSize(width: 12, height: 12) touchAreaInsets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) snp.makeConstraints { make in make.width.equalTo(24) make.height.equalTo(24) } layer.cornerRadius = 12 backgroundColor = .c.cpvn clipsToBounds = true leftImageView = UIImageView() leftImageView.image = iconOfWhitePlay() addSubview(leftImageView) leftImageView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 12, height: 12)) } let animation = LottieAnimation.named("voice_white")// voiceAnimation = LottieAnimationView(animation: animation) voiceAnimation.isUserInteractionEnabled = false voiceAnimation.loopMode = .loop voiceAnimation.animationSpeed = 1.4 voiceAnimation.contentMode = .scaleAspectFit voiceAnimation.isHidden = true voiceAnimation.tintColor = .black addSubview(voiceAnimation) voiceAnimation.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 12, height: 12)) } indicator = IndicatorView(color: .white) // .withAlphaComponent(0.5) indicator.size = CGSize(width: 12, height: 12) indicator.isHidden = true addSubview(indicator) indicator.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(CGSize(width: 12, height: 12)) } } // MARK: - Icons func iconOfWhitePlay() -> UIImage? { return MWIconFont.image(fromIcon: .play, size: self.iconSize, color: .white) } func iconOfWhiteVoice() -> UIImage? { return MWIconFont.image(fromIcon: .voiceLive, size: self.iconSize, color: .white) } func blackIcon() -> UIImage? { return MWIconFont.image(fromIcon: .play, size: self.iconSize, color: .c.csbn) } // MARK: - Public Methods func startIndicator() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.indicator.isAnimating { self.leftImageView.isHidden = true self.voiceAnimation.isHidden = true return } self.indicator.startAnimating() self.leftImageView.isHidden = true self.voiceAnimation.isHidden = true } } func stopIndicator() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if !self.indicator.isAnimating { self.leftImageView.isHidden = false self.voiceAnimation.isHidden = true return } self.leftImageView.isHidden = false self.voiceAnimation.isHidden = true self.indicator.stopAnimating() } } func startAnimation() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.voiceAnimation.isAnimationPlaying { return } self.voiceAnimation.isHidden = false self.leftImageView.isHidden = true self.voiceAnimation.play() } } func stopAnimation() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if !self.voiceAnimation.isAnimationPlaying { return } self.voiceAnimation.stop() self.voiceAnimation.isHidden = true self.leftImageView.isHidden = false } } func reloadState(with model: SpeechModel?) { guard let speechModel = model else{ reloadViews(model: model) return } self.path = speechModel.path speechModel.stateChangedBlock = { [weak self] updatedModel in guard let self = self, self.path == updatedModel.path else { return } self.reloadViews(model: updatedModel) self.checkState(model: updatedModel) } reloadViews(model: speechModel) } // MARK: - Private Methods private func reloadViews(model: SpeechModel?) { guard let audioModel = model else{ stopIndicator() stopAnimation() return } if audioModel.loadState == .loading { startIndicator() } else { stopIndicator() } if audioModel.playState == .playing { startAnimation() } else { stopAnimation() } } private func checkState(model: SpeechModel) { guard model.loadState == .complete else { // SpeechManager.shared.stopPlay(with: model) // ❌ return } switch model.playState { case .default: if model.canAutoPlay && isDisplay { SpeechManager.shared.startPlay(with: model) } case .complete, .failed: SpeechManager.shared.stopPlay(with: model) case .playing: break } } // MARK: - Deinit deinit { // 🔥 Replacing DLog with print print("♻️ AudioButton dealloc") } }