// // PigeonMessageVC.swift // QuickLocation // import UIKit import AVFoundation import CoreLocation import SwiftyUserDefaults import RxSwift final class PigeonMessageVC: BaseViewController { override var isNavigationBarHidden: Bool { true } override var prefersStatusBarHidden: Bool { true } private var rootView: PigeonMessageView! private var members: [GroupMemberModel] = [] private var selectedMember: 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 private lazy var imagePicker: ImagePicker = { let picker = ImagePicker() picker.imageDidPicked = { [weak self] image in DispatchQueue.main.async { self?.selectedImage = image self?.rootView.setSelectedImage(image) } } return picker }() 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.backButton.addTarget(self, action: #selector(goBack), for: .touchUpInside) rootView.historyButton.addTarget(self, action: #selector(showHistory), for: .touchUpInside) rootView.changeRecipientButton.addTarget( self, action: #selector(changeRecipient), for: .touchUpInside ) 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 DLToast.dismiss() guard let self, let model = response.model else { return } self.members = model.select_group_employee self.selectedMember = self.members.first self.refreshRecipient() }).disposed(by: disposeBag) } private func refreshRecipient() { rootView.configureRecipient( selectedMember, distanceText: distanceText(for: selectedMember) ) } private func distanceText(for member: GroupMemberModel?) -> String { guard let member, let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] else { return "与您相距 --" } let parts = member.last_position.components(separatedBy: ":") guard parts.count >= 2, let memberLatitude = Double(parts[0]), let memberLongitude = Double(parts[1]) else { return "与您相距 --" } let current = CLLocation(latitude: latitude, longitude: longitude) let target = CLLocation(latitude: memberLatitude, longitude: memberLongitude) let kilometers = current.distance(from: target) / 1000 if kilometers < 1 { return "与您相距 \(Int(kilometers * 1000))m" } return String(format: "与您相距 %.1fkm", kilometers) } @objc private func goBack() { navigationController?.popViewController(animated: true) } @objc private func showHistory() { DLToast.show(text: "传书记录敬请期待") } @objc private func changeRecipient() { guard !members.isEmpty else { DLToast.show(text: "暂无可选收件人") return } PigeonRecipientPickerView.show( members: members, selectedId: selectedMember?.user_id ?? "" ) { [weak self] member in self?.selectedMember = member self?.refreshRecipient() } } @objc private func selectImage() { imagePicker.presentImagePickerPopup() } @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: 60) 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 >= 59.95 { 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 } recordedDuration = min(60, 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 selectedMember != nil else { DLToast.show(text: "请先选择收件人") return } switch rootView.mode { case .image: guard selectedImage != nil else { DLToast.show(text: "请先选择一张图片") return } selectedImage = nil rootView.clearImageDraft() case .voice: guard recordURL != nil, recordedDuration >= 1 else { DLToast.show(text: "请先完成录音") return } cleanupAudio(deleteFile: true) rootView.clearVoiceDraft() } DLToast.show(text: "发送成功") } 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) } }