jsdw_ios/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift

417 lines
14 KiB
Swift

//
// PigeonMessageVC.swift
// QuickLocation
//
import UIKit
import AVFoundation
import CoreLocation
import SwiftyUserDefaults
import RxSwift
final class PigeonMessageVC: BaseViewController {
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
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.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
guard let self else { return }
guard let model = response.model else {
DLToast.dismiss()
DLToast.showError(text: response.message ?? "获取圈子信息失败")
return
}
guard !model.default_group_key.isEmpty else {
DLToast.dismiss()
self.applyMembers([])
return
}
GroupService.groupUsers(groupKey: model.default_group_key)
.subscribe(onNext: { [weak self] response in
DLToast.dismiss()
guard response.isValid(for: model.default_group_key) else {
DLToast.showError(text: response.message ?? "获取圈子成员失败")
return
}
self?.applyMembers(response.list)
}, onError: { error in
DLToast.dismiss()
DLToast.showError(text: error.localizedDescription)
})
.disposed(by: self.disposeBag)
}, onError: { error in
DLToast.dismiss()
DLToast.showError(text: error.localizedDescription)
}).disposed(by: disposeBag)
}
private func applyMembers(_ members: [GroupMemberModel]) {
self.members = members
selectedMember = members.first
refreshRecipient()
}
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() {
let historyVC = PigeonMessageHistoryVC(members: members)
navigationController?.pushViewController(historyVC, animated: true)
}
@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() {
let picker = PhotoPickerVC(maxCount: 1)
picker.onConfirm = { [weak self] images in
guard let image = images.first else { return }
self?.selectedImage = image
self?.rootView.setSelectedImage(image)
}
present(picker, animated: true)
}
@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)
}
}