// // PigeonMessageVC.swift // QuickLocation // import UIKit import AVFoundation import RxSwift final class PigeonMessageVC: BaseViewController { private var rootView: PigeonMessageView! private var groupModel: GroupModel? private var activeGroupKey: String = "" private var members: [GroupMemberModel] = [] private var selectedMembers: [GroupMemberModel] = [] private var selectedImage: UIImage? private var audioRecorder: AVAudioRecorder? private var audioPlayer: AVAudioPlayer? private var recordURL: URL? private var recordTimer: Timer? private var playbackTimer: Timer? private var recordedDuration: TimeInterval = 0 private var isPressingRecord = false override func loadView() { rootView = PigeonMessageView(frame: UIScreen.main.bounds) view = rootView } override func viewDidLoad() { super.viewDidLoad() bindActions() setupRecordingGesture() loadMembers() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) guard isMovingFromParent || navigationController?.isBeingDismissed == true else { return } cleanupAudio(deleteFile: true) } private func bindActions() { rootView.switchGroupButton.addTarget(self, action: #selector(switchGroup), for: .touchUpInside) rootView.historyButton.addTarget(self, action: #selector(showHistory), for: .touchUpInside) rootView.onSelectionChanged = { [weak self] members in self?.selectedMembers = members } rootView.addImageButton.addTarget(self, action: #selector(selectImage), for: .touchUpInside) rootView.deleteImageButton.addTarget(self, action: #selector(deleteImage), for: .touchUpInside) rootView.playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) rootView.sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside) rootView.onModeChanged = { [weak self] _ in self?.stopPlayback() if self?.audioRecorder?.isRecording == true { self?.stopRecording(cancel: true) } } } private func setupRecordingGesture() { let gesture = UILongPressGestureRecognizer( target: self, action: #selector(handleRecordGesture(_:)) ) gesture.minimumPressDuration = 0.15 rootView.recordButton.addGestureRecognizer(gesture) } private func loadMembers() { DLToast.showLoading() GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self else { return } guard let model = response.model else { DLToast.dismiss() DLToast.showError(text: response.message ?? "获取圈子信息失败") return } self.groupModel = model self.activeGroupKey = model.default_group_key self.loadUsers(for: self.activeGroupKey, showLoading: false) }, onError: { error in DLToast.dismiss() DLToast.showError(text: error.localizedDescription) }).disposed(by: disposeBag) } private func loadUsers(for groupKey: String, showLoading: Bool) { guard !groupKey.isEmpty else { DLToast.dismiss() applyMembers([]) return } if showLoading { DLToast.showLoading() } GroupService.groupUsers(groupKey: groupKey) .subscribe(onNext: { [weak self] response in DLToast.dismiss() guard response.isValid(for: groupKey) else { DLToast.showError(text: response.message ?? "获取圈子成员失败") return } self?.applyMembers(response.list) }, onError: { error in DLToast.dismiss() DLToast.showError(text: error.localizedDescription) }) .disposed(by: disposeBag) } private func applyMembers(_ members: [GroupMemberModel]) { self.members = members selectedMembers = [] rootView.configureMembers(members, selectedIds: []) } @objc private func goBack() { navigationController?.popViewController(animated: true) } @objc private func switchGroup() { guard let groupModel else { loadMembers() return } GroupListPopView.show( groupModel: groupModel, currentMembers: members, selectedKey: activeGroupKey ) { [weak self] groupKey in guard let self, let key = groupKey, key != self.activeGroupKey else { return } self.activeGroupKey = key self.loadUsers(for: key, showLoading: true) } } @objc private func showHistory() { let historyVC = PigeonMessageHistoryVC(groupKey: activeGroupKey) navigationController?.pushViewController(historyVC, animated: true) } @objc private func selectImage() { PigeonUploadPopView.show( onAlbum: { [weak self] in self?.openAlbumPicker() }, onCamera: { [weak self] in self?.openWatermarkCamera() } ) } private func openAlbumPicker() { let picker = PhotoPickerVC(maxCount: 1) picker.onConfirm = { [weak self] images in guard let image = images.first else { return } self?.applySelectedImage(image) } present(picker, animated: true) } private func openWatermarkCamera() { guard UIImagePickerController.isSourceTypeAvailable(.camera) else { DLToast.showError(text: "当前设备不支持拍照") return } guard let manager = AuthorizeManager.manager(type: .camera) else { return } switch manager.authorizeStatus() { case .authorized: pushWatermarkCamera() case .notDetermined: manager.authorize { [weak self] status in if status == .authorized { self?.pushWatermarkCamera() } else if status != .authorized { self?.showCameraDenied() } } case .restricted, .denied: showCameraDenied() } } private func pushWatermarkCamera() { let cameraVC = PigeonWatermarkCameraVC() cameraVC.onPhoto = { [weak self] image in self?.applySelectedImage(image) } navigationController?.pushViewController(cameraVC, animated: true) } private func applySelectedImage(_ image: UIImage) { selectedImage = image rootView.setSelectedImage(image) } private func showCameraDenied() { Permission.openAppSetting( title: "无法使用相机", message: "请在系统设置中允许访问相机" ) } @objc private func deleteImage() { selectedImage = nil rootView.setSelectedImage(nil) } @objc private func handleRecordGesture(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: isPressingRecord = true requestMicrophoneAndStart() case .ended: isPressingRecord = false if audioRecorder?.isRecording == true { stopRecording(cancel: false) } case .cancelled, .failed: isPressingRecord = false stopRecording(cancel: true) default: break } } private func requestMicrophoneAndStart() { guard let manager = AuthorizeManager.manager(type: .microphone) else { return } switch manager.authorizeStatus() { case .authorized: if isPressingRecord { startRecording() } case .notDetermined: manager.authorize { [weak self] status in guard let self else { return } if status == .authorized, self.isPressingRecord { self.startRecording() } else if status != .authorized { self.showMicrophoneDenied() } } case .restricted, .denied: showMicrophoneDenied() } } private func showMicrophoneDenied() { Permission.openAppSetting( title: "无法使用麦克风", message: "请在系统设置中允许访问麦克风" ) } private func startRecording() { stopPlayback() if let recordURL { try? FileManager.default.removeItem(at: recordURL) } do { let session = AVAudioSession.sharedInstance() try session.setCategory( .playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP] ) try session.setActive(true) let url = FileManager.default.temporaryDirectory .appendingPathComponent("pigeon_\(UUID().uuidString).wav") let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 8000, AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false ] let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.isMeteringEnabled = true recorder.prepareToRecord() recorder.record(forDuration: PigeonMessageView.maxVoiceDuration + 0.25) recordURL = url audioRecorder = recorder recordedDuration = 0 rootView.clearVoiceDraft() rootView.setRecording(isRecording: true) rootView.setVoiceDuration(0, hasRecording: false) startRecordTimer() } catch { DLToast.show(text: "录音启动失败") stopRecording(cancel: true) } } private func startRecordTimer() { recordTimer?.invalidate() let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in guard let self, let recorder = self.audioRecorder else { return } recorder.updateMeters() let level = pow(10, CGFloat(recorder.averagePower(forChannel: 0)) / 38) self.rootView.pushMeterLevel(level) self.rootView.setVoiceDuration(recorder.currentTime, hasRecording: false) if recorder.currentTime >= PigeonMessageView.maxVoiceDuration { self.isPressingRecord = false self.stopRecording(cancel: false) } } RunLoop.main.add(timer, forMode: .common) recordTimer = timer } private func stopRecording(cancel: Bool) { recordTimer?.invalidate() recordTimer = nil let duration = audioRecorder?.currentTime ?? 0 audioRecorder?.stop() audioRecorder = nil rootView.setRecording(isRecording: false) try? AVAudioSession.sharedInstance().setActive(false) guard !cancel, duration >= 1, recordURL != nil else { if !cancel, duration > 0 { DLToast.show(text: "录音时间不能少于1秒") } if let recordURL { try? FileManager.default.removeItem(at: recordURL) } self.recordURL = nil recordedDuration = 0 rootView.clearVoiceDraft() return } let maxDuration = PigeonMessageView.maxVoiceDuration recordedDuration = duration + 0.05 >= maxDuration ? maxDuration : min(maxDuration, duration) rootView.setVoiceDuration(recordedDuration, hasRecording: true) rootView.setPlaying(false) } @objc private func togglePlayback() { guard let recordURL else { return } if let player = audioPlayer { if player.isPlaying { player.pause() rootView.setPlaying(false) playbackTimer?.invalidate() playbackTimer = nil } else { player.play() rootView.setPlaying(true) startPlaybackTimer() } return } do { let player = try AVAudioPlayer(contentsOf: recordURL) player.delegate = self player.isMeteringEnabled = true player.prepareToPlay() player.play() audioPlayer = player rootView.setPlaying(true) startPlaybackTimer() } catch { DLToast.show(text: "语音播放失败") } } private func startPlaybackTimer() { playbackTimer?.invalidate() let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in guard let self, let player = self.audioPlayer else { return } player.updateMeters() let level = pow(10, CGFloat(player.averagePower(forChannel: 0)) / 38) self.rootView.pushMeterLevel(level) self.rootView.setVoiceDuration(player.currentTime, hasRecording: true) } RunLoop.main.add(timer, forMode: .common) playbackTimer = timer } private func stopPlayback() { playbackTimer?.invalidate() playbackTimer = nil audioPlayer?.stop() audioPlayer = nil rootView?.setPlaying(false) if recordedDuration > 0 { rootView?.setVoiceDuration(recordedDuration, hasRecording: true) } } @objc private func sendMessage() { guard !selectedMembers.isEmpty else { DLToast.show(text: "请先选择收件人") return } guard !activeGroupKey.isEmpty else { DLToast.show(text: "请先选择圈子") return } let upload: Observable let msgType: Int switch rootView.mode { case .image: guard let image = selectedImage, let jpeg = compressedPigeonJPEG(from: image) else { DLToast.show(text: "请先选择一张图片") return } upload = UploadService.upload(jpeg, kind: .jpeg, scene: "pigeon") msgType = 1 case .voice: guard let recordURL, recordedDuration >= 1, let voiceData = try? Data(contentsOf: recordURL) else { DLToast.show(text: "请先完成录音") return } upload = UploadService.upload(voiceData, kind: .wav, scene: "pigeon") msgType = 2 } DLToast.showLoading() upload .flatMap { [weak self] fileId -> Observable in guard let self else { return .empty() } return PigeonService.send( groupKey: self.activeGroupKey, toUsers: self.selectedMembers.map(\.user_id), msgType: msgType, fileId: fileId, textMsg: self.rootView.captionText.trimmingCharacters(in: .whitespacesAndNewlines) ) } .subscribe(onNext: { [weak self] _ in DLToast.dismiss() guard let self else { return } switch self.rootView.mode { case .image: self.selectedImage = nil self.rootView.clearImageDraft() case .voice: self.cleanupAudio(deleteFile: true) self.rootView.clearVoiceDraft() } DLToast.show(text: "发送成功") }, onError: { error in DLToast.dismiss() DLToast.show(text: error.localizedDescription) }) .disposed(by: disposeBag) } private func compressedPigeonJPEG(from image: UIImage) -> Data? { let maxEdge: CGFloat = 1280 let quality: CGFloat = 0.55 let longest = max(image.size.width, image.size.height) let output: UIImage if longest > maxEdge, longest > 0 { let scale = maxEdge / longest let size = CGSize( width: (image.size.width * scale).rounded(), height: (image.size.height * scale).rounded() ) let format = UIGraphicsImageRendererFormat.default() format.scale = 1 format.opaque = true output = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } } else { output = image } return output.jpegData(compressionQuality: quality) ?? image.jpegData(compressionQuality: 0.8) } private func cleanupAudio(deleteFile: Bool) { recordTimer?.invalidate() playbackTimer?.invalidate() recordTimer = nil playbackTimer = nil audioRecorder?.stop() audioRecorder = nil audioPlayer?.stop() audioPlayer = nil if deleteFile, let recordURL { try? FileManager.default.removeItem(at: recordURL) self.recordURL = nil } recordedDuration = 0 try? AVAudioSession.sharedInstance().setActive(false) } } extension PigeonMessageVC: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { playbackTimer?.invalidate() playbackTimer = nil audioPlayer = nil rootView.setPlaying(false) rootView.setVoiceDuration(recordedDuration, hasRecording: recordedDuration >= 1) } }