// // LNIMChatVoiceInputView.swift // Lanu // // Created by OneeChan on 2025/12/4. // import Foundation import UIKit import SnapKit protocol LNIMChatVoiceInputViewDelegate: AnyObject { func onVoiceFinishInput() } class LNIMChatVoiceInputView: UIView { private let durationLabel = UILabel() private let waveView = LNIMChatVoiceWaveView() private let controlButton = UIButton() private var recordTaskId: String? private let maxRecord: Double = 60 weak var delegate: LNIMChatVoiceInputViewDelegate? weak var viewModel: LNIMChatViewModel? override init(frame: CGRect) { super.init(frame: frame) setupViews() LNEventDeliver.addObserver(self) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNIMChatVoiceInputView { func startRecord() { LNVoicePlayer.shared.stop() LNVoiceRecorder.shared.startRecord(maxRecord) { [weak self] taskId in guard let self else { return } recordTaskId = taskId } } } extension LNIMChatVoiceInputView: LNVoiceRecorderNotify { func onRecordTaskReachMaxDuration(taskId: String, fileUrl: URL?, duration: Double) { if let fileUrl { viewModel?.sendVoiceMessage(voicePath: fileUrl.path, duration: duration) } } func onRecordTaskStop(taskId: String) { guard taskId == recordTaskId else { return } controlButton.setImage(.icImChatVoiceInputPause, for: .normal) durationLabel.text = "00:00" waveView.clear() delegate?.onVoiceFinishInput() } func onRecordTaskPause(taskId: String) { guard taskId == recordTaskId else { return } controlButton.setImage(.icImChatVoiceInputContinue, for: .normal) } func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) { guard taskId == recordTaskId else { return } durationLabel.text = .init(format: "%02d:%02d", Int(duration) / 60, Int(duration) % 60) waveView.add(volumeRatio) } func onRecordTaskRecording(taskId: String) { guard taskId == recordTaskId else { return } controlButton.setImage(.icImChatVoiceInputPause, for: .normal) } } extension LNIMChatVoiceInputView { private func setupViews() { backgroundColor = .fill let infoView = buildVoiceInfoView() addSubview(infoView) infoView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } let controlView = buildVoiceControlView() addSubview(controlView) controlView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview().offset(-safeBottomInset) make.top.equalTo(infoView.snp.bottom) } } private func buildVoiceInfoView() -> UIView { let container = UIView() container.snp.makeConstraints { make in make.height.equalTo(40) } durationLabel.font = .heading_h2 durationLabel.textColor = .text_5 durationLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) durationLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) container.addSubview(durationLabel) durationLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(16) } container.addSubview(waveView) waveView.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.leading.equalToSuperview().offset(82) make.height.equalTo(28) make.centerY.equalToSuperview() } return container } private func buildVoiceControlView() -> UIView { let container = UIView() container.snp.makeConstraints { make in make.height.equalTo(40) } let deleteButton = UIButton() deleteButton.setImage(.icImChatVoiceInputDelete, for: .normal) deleteButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } delegate?.onVoiceFinishInput() LNVoiceRecorder.shared.stopRecord() waveView.clear() }), for: .touchUpInside) container.addSubview(deleteButton) deleteButton.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(12) } controlButton.setImage(.icImChatVoiceInputPause, for: .normal) controlButton.addAction(UIAction(handler: { [weak self] _ in guard self != nil else { return } if LNVoiceRecorder.shared.curState == .recording { LNVoiceRecorder.shared.pauseRecord() } else if LNVoiceRecorder.shared.curState == .pausing { LNVoiceRecorder.shared.continueRecord() } }), for: .touchUpInside) container.addSubview(controlButton) controlButton.snp.makeConstraints { make in make.center.equalToSuperview() } let sendButton = UIButton() sendButton.setImage(.icImChatSend, for: .normal) sendButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } let (path, duration) = LNVoiceRecorder.shared.stopRecord() if let path { viewModel?.sendVoiceMessage(voicePath: path.path, duration: duration) } }), for: .touchUpInside) container.addSubview(sendButton) sendButton.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-12) } return container } } #if DEBUG import SwiftUI struct LNIMChatVoiceInputViewPreview: UIViewRepresentable { func makeUIView(context: Context) -> some UIView { let container = UIView() container.backgroundColor = .lightGray let view = LNIMChatVoiceInputView() container.addSubview(view) view.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } return container } func updateUIView(_ uiView: UIViewType, context: Context) { } } #Preview(body: { LNIMChatVoiceInputViewPreview() }) #endif