// // LNSkillFieldVoiceEditView.swift // Gami // // Created by OneeChan on 2026/1/21. // import Foundation import UIKit import SnapKit class LNSkillFieldVoiceEditView: LNSkillFieldBaseEditView { private var minDuration: Double? private var maxDuration: Double? private let recordView = UIView() private let recordButton = UIButton() private let recordDurationLabel = UILabel() private let recordText = UILabel() private var recordTaskId: String? private let displayView = UIView() private let playIcon = UIImageView() private let voiceWaveView = LNVoiceWaveView() private let playDurationLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) setupViews() LNEventDeliver.addObserver(self) } override func update(_ field: LNSkillEditField) { super.update(field) if let limit = field.validate.size { minDuration = Double(limit.min) maxDuration = Double(limit.max) } if let url = field.value as? String, !url.isEmpty { recordView.isHidden = true displayView.isHidden = false if field.duration == 0 { LNVoiceResourceManager.shared.getRemoteAudioDuration(urlStr: url) { [weak self] duration, _ in guard let self else { return } guard let duration else { return } guard field.value as? String == url else { return } field.duration = duration.toDuration playDurationLabel.text = "\(field.duration)“" } } else { playDurationLabel.text = "\(field.duration)“" } } } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNSkillFieldVoiceEditView { private func handleRecordResult(url: URL?, duration: Double) { guard let url else { return } if let minDuration, duration < minDuration { showToast(.init(key: "B00009", minDuration)) return } field?.value = url.path field?.duration = duration.toDuration playDurationLabel.text = duration.durationDisplay recordView.isHidden = true resetRecord() displayView.isHidden = false needReview = true delegate?.onSkillFieldBaseEditViewInputChanged(view: self) } } extension LNSkillFieldVoiceEditView: LNVoicePlayerNotify { func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) { guard field?.value as? String == path else { return } playDurationLabel.text = (total - cur).durationDisplay } func onAudioStopPlay(path: String) { guard field?.value as? String == path else { return } guard let curDuration = field?.duration else { return } playDurationLabel.text = "\(curDuration)“" playIcon.image = .icVoicePlay voiceWaveView.stopAnimate() } func onAudioStartPlay(path: String) { guard field?.value as? String == path else { return } playIcon.image = .icVoicePause voiceWaveView.startAnimate() } } extension LNSkillFieldVoiceEditView: LNVoiceRecorderNotify { func onRecordTaskDurationChanged(taskId: String, duration: Double, volumeRatio: Double) { guard recordTaskId == taskId else { return } recordDurationLabel.text = duration.timeCountDisplay } func onRecordTaskRecording(taskId: String) { guard recordTaskId == taskId else { return } recordText.text = .init(key: "B00008") recordButton.setImage(.icVoiceEditStop, for: .normal) } func onRecordTaskStop(taskId: String) { guard recordTaskId == taskId else { return } resetRecord() } func onRecordTaskReachMaxDuration(taskId: String, fileUrl: URL?, duration: Double) { guard recordTaskId == taskId else { return } handleRecordResult(url: fileUrl, duration: duration) } } extension LNSkillFieldVoiceEditView { private func resetRecord() { recordDurationLabel.text = "00:00" recordButton.setImage(.icVoiceEditStart, for: .normal) recordText.text = .init(key: "B00007") } private func setupViews() { container.layer.cornerRadius = 12 container.backgroundColor = .fill_1 container.snp.makeConstraints { make in make.height.equalTo(158) } let recordView = buildRecordView() container.addSubview(recordView) recordView.snp.makeConstraints { make in make.center.equalToSuperview() } let playView = buildDisplayView() container.addSubview(playView) playView.snp.makeConstraints { make in make.center.equalToSuperview() } } private func buildRecordView() -> UIView { recordDurationLabel.font = .body_m recordDurationLabel.textColor = .text_5 recordView.addSubview(recordDurationLabel) recordDurationLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview() } recordView.addSubview(recordButton) recordButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } LNVoicePlayer.shared.stop() if LNVoiceRecorder.shared.isRecording { let (url, duration) = LNVoiceRecorder.shared.stopRecord() handleRecordResult(url: url, duration: duration) } else { LNVoiceRecorder.shared.startRecord(maxDuration) { [weak self] taskId in guard let self else { return } recordTaskId = taskId } } }), for: .touchUpInside) recordButton.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(recordDurationLabel.snp.bottom).offset(12) make.leading.greaterThanOrEqualToSuperview() } recordText.font = .body_m recordText.textColor = .text_4 recordView.addSubview(recordText) recordText.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(recordButton.snp.bottom).offset(12) make.bottom.equalToSuperview() } resetRecord() return recordView } private func buildDisplayView() -> UIView { displayView.isHidden = true let playView = UIView() displayView.addSubview(playView) playView.snp.makeConstraints { make in make.verticalEdges.equalToSuperview() make.leading.equalToSuperview() } let button = UIButton() button.setBackgroundImage(.primary_7, for: .normal) button.layer.cornerRadius = 19 button.clipsToBounds = true playView.addSubview(button) button.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() make.width.equalTo(206) make.height.equalTo(38) } playIcon.image = .icVoicePlay button.addSubview(playIcon) playIcon.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(3) make.width.height.equalTo(32) } voiceWaveView.isUserInteractionEnabled = false voiceWaveView.itemWidth = 3 voiceWaveView.build() button.addSubview(voiceWaveView) voiceWaveView.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(playIcon.snp.trailing).offset(7) make.width.equalTo(30) make.height.equalTo(18) } playDurationLabel.font = .heading_h4 playDurationLabel.textColor = .text_1 button.addSubview(playDurationLabel) playDurationLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-8) } button.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } if LNVoicePlayer.shared.isPlaying { LNVoicePlayer.shared.stop() } else if let path = field?.value as? String { LNVoicePlayer.shared.play(path: path) } }), for: .touchUpInside) let playLabel = UILabel() playLabel.text = .init(key: "B00011") playLabel.font = .body_s playLabel.textColor = .text_4 playLabel.textAlignment = .center playView.addSubview(playLabel) playLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() make.top.equalTo(button.snp.bottom).offset(11) } let redoView = UIView() displayView.addSubview(redoView) redoView.snp.makeConstraints { make in make.verticalEdges.equalToSuperview() make.trailing.equalToSuperview() make.leading.equalTo(playView.snp.trailing).offset(13) make.width.equalTo(60) } let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold) let redoButton = UIButton() redoButton.backgroundColor = .fill_4 redoButton.layer.cornerRadius = 17 redoButton.setImage(.init(systemName: "xmark", withConfiguration: config)?.withRenderingMode(.alwaysTemplate), for: .normal) redoButton.tintColor = .fill redoButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } LNVoicePlayer.shared.stop() field?.value = nil field?.duration = 0 recordView.isHidden = false displayView.isHidden = true }), for: .touchUpInside) redoView.addSubview(redoButton) redoButton.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview() make.width.height.equalTo(34) } let redoLabel = UILabel() redoLabel.text = .init(key: "B00010") redoLabel.font = .body_s redoLabel.textColor = .text_4 redoLabel.textAlignment = .center redoView.addSubview(redoLabel) redoLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() } return displayView } }