// // LNEditVoicePanel.swift // Gami // // Created by OneeChan on 2026/1/14. // import Foundation import UIKit import SnapKit enum LNVoiceEditState { case record case edit case review case play } class LNEditVoicePanel: LNPopupView { private let minDuration: Double = 3 private let maxDuration: Double = 60 private let recordView = UIView() private let recordDurationLabel = UILabel() private let recordButton = UIButton() private let recordText = UILabel() private var recordTaskId: String? private let editView = UIView() private let editPlayButton = UIButton() private let editDurationLabel = UILabel() private var curUrl: URL? private var curDuration: Double? private let reviewView = UIView() private let displayView = UIView() private let playIcon = UIImageView() private let voiceWaveView = LNVoiceWaveView() private let playDurationLabel = UILabel() private var curState: LNVoiceEditState = .record { didSet { recordView.isHidden = curState != .record editView.isHidden = curState != .edit reviewView.isHidden = curState != .review displayView.isHidden = curState != .play } } override init(frame: CGRect) { super.init(frame: frame) setupViews() adjustByUserInfo() onTouchOutside = { [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 if curState == .edit { let alert = LNCommonAlertView() alert.titleLabel.text = .init(key: "B00018") alert.showConfirm { [weak self] in guard let self else { return } dismiss() } alert.showCancel() alert.popup() } else { dismiss() } } LNEventDeliver.addObserver(self) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNEditVoicePanel { private func adjustByUserInfo() { if myVoiceBarInfo.status == .review { curState = .review } else if myVoiceBarInfo.status == .done, !myUserInfo.voiceBar.isEmpty { curState = .play playDurationLabel.text = Double(myVoiceBarInfo.voiceBarDuration).durationDisplay } else { curState = .record } } private func handleRecordResult(url: URL?, duration: Double) { guard let url else { return } if duration < minDuration { showToast(.init(key: "B00009", minDuration)) return } curUrl = url curDuration = duration editDurationLabel.text = duration.timeCountDisplay curState = .edit } } extension LNEditVoicePanel: LNVoicePlayerNotify { func onAudioUpdateDuration(path: String, cur: TimeInterval, total: TimeInterval) { if !editView.isHidden { guard curUrl?.path == path else { return } let remain = Int(total - cur) editDurationLabel.text = remain.timeCountDisplay } else if !displayView.isHidden { guard path == myUserInfo.voiceBar else { return } playDurationLabel.text = (total - cur).durationDisplay } } func onAudioStopPlay(path: String) { if !editView.isHidden { guard curUrl?.path == path else { return } guard let curDuration else { return } editDurationLabel.text = curDuration.timeCountDisplay editPlayButton.setImage(.icVoiceEditPlay, for: .normal) } else if !displayView.isHidden { guard path == myUserInfo.voiceBar else { return } playDurationLabel.text = "\(myVoiceBarInfo.voiceBarDuration)“" playIcon.image = .icVoicePlay voiceWaveView.stopAnimate() } } func onAudioStartPlay(path: String) { if !editView.isHidden { guard curUrl?.path == path else { return } editPlayButton.setImage(.icVoiceEditPause, for: .normal) } else if !displayView.isHidden { guard path == myUserInfo.voiceBar else { return } playIcon.image = .icVoicePause voiceWaveView.startAnimate() } } } extension LNEditVoicePanel: 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 LNEditVoicePanel { private func resetRecord() { recordDurationLabel.text = "00:00" recordButton.setImage(.icVoiceEditStart, for: .normal) recordText.text = .init(key: "B00007") } private func setupViews() { let fakeView = UIView() fakeView.isUserInteractionEnabled = false container.addSubview(fakeView) fakeView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(326) } let titleView = buildTitle() container.addSubview(titleView) titleView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } let recordView = buildRecordView() container.addSubview(recordView) recordView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(titleView.snp.bottom) make.bottom.equalToSuperview() } let editView = buildEditView() container.addSubview(editView) editView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(titleView.snp.bottom) make.bottom.equalToSuperview() } let reviewView = buildReviewView() container.addSubview(reviewView) reviewView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(titleView.snp.bottom) make.bottom.equalToSuperview() } let displayView = buildDisplayView() container.addSubview(displayView) displayView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(titleView.snp.bottom) make.bottom.equalToSuperview() } } private func buildTitle() -> UIView { let container = UIView() container.snp.makeConstraints { make in make.height.equalTo(50) } let titleLabel = UILabel() titleLabel.text = .init(key: "B00006") container.addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.center.equalToSuperview() } return container } 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().offset(21) } recordView.addSubview(recordButton) recordButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } 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) } recordText.font = .body_m recordText.textColor = .text_4 recordView.addSubview(recordText) recordText.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(recordButton.snp.bottom).offset(12) } let confirmButton = UIButton() confirmButton.setTitle(.init(key: "A00240"), for: .normal) confirmButton.setTitleColor(.text_1, for: .normal) confirmButton.titleLabel?.font = .heading_h3 confirmButton.layer.cornerRadius = 23.5 confirmButton.backgroundColor = .fill_4 confirmButton.isEnabled = false recordView.addSubview(confirmButton) confirmButton.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(12) make.bottom.equalToSuperview().offset(commonBottomInset) make.height.equalTo(47) } resetRecord() return recordView } private func buildEditView() -> UIView { editView.isHidden = true editDurationLabel.font = .body_m editDurationLabel.textColor = .text_5 editView.addSubview(editDurationLabel) editDurationLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview() } let stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = 24 editView.addSubview(stackView) stackView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(editDurationLabel.snp.bottom).offset(34) } let remakeView = UIView() stackView.addArrangedSubview(remakeView) let remakeButton = UIButton() remakeButton.setImage(.icVoiceEditRemake, for: .normal) remakeButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } LNVoicePlayer.shared.stop() curUrl = nil curDuration = nil curState = .record }), for: .touchUpInside) remakeView.addSubview(remakeButton) remakeButton.snp.makeConstraints { make in make.centerX.equalToSuperview() make.leading.greaterThanOrEqualToSuperview() make.top.equalToSuperview() } let remakeLabel = UILabel() remakeLabel.text = .init(key: "B00010") remakeLabel.font = .body_m remakeLabel.textColor = .text_4 remakeLabel.textAlignment = .center remakeView.addSubview(remakeLabel) remakeLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() make.top.equalTo(remakeButton.snp.bottom).offset(12) } let playView = UIView() stackView.addArrangedSubview(playView) editPlayButton.setImage(.icVoiceEditPlay, for: .normal) editPlayButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard let curUrl else { return } if LNVoicePlayer.shared.isPlaying { LNVoicePlayer.shared.stop() } else { LNVoicePlayer.shared.play(path: curUrl.path) } }), for: .touchUpInside) playView.addSubview(editPlayButton) editPlayButton.snp.makeConstraints { make in make.centerX.equalToSuperview() make.leading.greaterThanOrEqualToSuperview() make.top.equalToSuperview() } let playLabel = UILabel() playLabel.text = .init(key: "B00011") playLabel.font = .body_m playLabel.textColor = .text_4 playLabel.textAlignment = .center playView.addSubview(playLabel) playLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() make.top.equalTo(editPlayButton.snp.bottom).offset(12) } let confirmButton = UIButton() confirmButton.setTitle(.init(key: "A00240"), for: .normal) confirmButton.setTitleColor(.text_1, for: .normal) confirmButton.titleLabel?.font = .heading_h3 confirmButton.layer.cornerRadius = 23.5 confirmButton.setBackgroundImage(.primary_8, for: .normal) confirmButton.clipsToBounds = true confirmButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard let curUrl, let curDuration else { return } LNVoicePlayer.shared.stop() showLoading() LNFileUploader.shared.startUpload(type: .voice, fileURL: curUrl, progressHandler: nil) { [weak self] url, err in dismissLoading() guard let self else { return } guard let url, err == nil else { showToast(err) return } LNProfileManager.shared.setMyVoiceBar(url: url, duration: curDuration.toDuration) { [weak self] success in guard let self else { return } guard success else { return } dismiss() } } }), for: .touchUpInside) editView.addSubview(confirmButton) confirmButton.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(12) make.bottom.equalToSuperview().offset(commonBottomInset) make.height.equalTo(47) } return editView } private func buildReviewView() -> UIView { let icon = UIImageView() icon.image = .icVoiceEditReview reviewView.addSubview(icon) icon.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview().offset(47) } let titleLabel = UILabel() titleLabel.text = .init(key: "B00012") titleLabel.textColor = .text_5 titleLabel.font = .heading_h4 reviewView.addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(icon.snp.bottom).offset(12) } let descLabel = UILabel() descLabel.text = .init(key: "B00013") descLabel.font = .body_m descLabel.textColor = .text_5 reviewView.addSubview(descLabel) descLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalTo(titleLabel.snp.bottom).offset(3) } return reviewView } private func buildDisplayView() -> UIView { let button = UIButton() button.setBackgroundImage(.primary_7, for: .normal) button.layer.cornerRadius = 16 button.clipsToBounds = true displayView.addSubview(button) button.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview().offset(59) make.width.equalTo(163) make.height.equalTo(32) } playIcon.image = .icVoicePlay button.addSubview(playIcon) playIcon.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(3) make.width.height.equalTo(22) } voiceWaveView.isUserInteractionEnabled = false voiceWaveView.build() button.addSubview(voiceWaveView) voiceWaveView.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(playIcon.snp.trailing).offset(7) make.width.equalTo(19) make.height.equalTo(11) } playDurationLabel.font = .heading_h5 playDurationLabel.textColor = .text_1 button.addSubview(playDurationLabel) playDurationLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-11) } button.addAction(UIAction(handler: { [weak self] _ in guard self != nil else { return } if LNVoicePlayer.shared.isPlaying { LNVoicePlayer.shared.stop() } else if !myUserInfo.voiceBar.isEmpty { LNVoicePlayer.shared.play(myUserInfo.voiceBar) } }), for: .touchUpInside) let confirmButton = UIButton() confirmButton.setTitle(.init(key: "B00107"), for: .normal) confirmButton.setTitleColor(.text_6, for: .normal) confirmButton.titleLabel?.font = .heading_h3 confirmButton.layer.cornerRadius = 23.5 confirmButton.setBackgroundImage(.primary_7, for: .normal) confirmButton.clipsToBounds = true confirmButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } LNVoicePlayer.shared.stop() LNProfileManager.shared.cleanVoiceBar { [weak self] success in guard let self else { return } guard success else { return } resetRecord() curState = .record } }), for: .touchUpInside) displayView.addSubview(confirmButton) confirmButton.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(12) make.bottom.equalToSuperview().offset(commonBottomInset) make.height.equalTo(47) } let cover = UIView() cover.layer.cornerRadius = 22 cover.backgroundColor = .fill cover.isUserInteractionEnabled = false confirmButton.insertSubview(cover, at: 0) cover.snp.makeConstraints { make in make.edges.equalToSuperview().inset(1) } return displayView } }