jsdw_ios/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift

570 lines
20 KiB
Swift

//
// 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 selectedTemplate: PigeonLocalTemplate?
private var selectedVoiceTemplate: PigeonVoiceTemplate?
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.onUploadTapped = { [weak self] in
self?.selectImage()
}
rootView.onTemplateSelected = { [weak self] template in
self?.applySelectedTemplate(template)
}
rootView.onVoiceTemplateSelected = { [weak self] template in
self?.selectedVoiceTemplate = template
self?.stopPlayback()
}
rootView.deleteImageButton.addTarget(self, action: #selector(deleteCaption), 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.updateCurrentGroupDisplay()
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: [])
}
private func updateCurrentGroupDisplay() {
let groupName = groupModel?.groups.first {
$0.group_key == activeGroupKey
}?.name ?? ""
rootView.setCurrentGroupName(groupName)
}
@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.updateCurrentGroupDisplay()
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) {
selectedTemplate = nil
selectedImage = image
rootView.setSelectedImage(image)
}
private func applySelectedTemplate(_ template: PigeonLocalTemplate) {
guard let image = template.contentImage else {
DLToast.show(text: "模板资源不可用")
return
}
selectedTemplate = template
selectedImage = image
rootView.setSelectedImage(image, selectedTemplateID: template.id)
}
private func showCameraDenied() {
Permission.openAppSetting(
title: "无法使用相机",
message: "请在系统设置中允许访问相机"
)
}
@objc private func deleteCaption() {
rootView.clearCaption()
}
@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
}
guard selectedImage != nil else {
DLToast.show(text: "请先选择一张图片")
return
}
let isVoice = rootView.mode == .voice
if isVoice, selectedVoiceTemplate == nil, recordedDuration < 1 {
DLToast.show(text: "请先完成录音")
return
}
let msgType = isVoice ? 2 : 3
let caption = rootView.captionText.trimmingCharacters(in: .whitespacesAndNewlines)
var extra: [String: Any] = [:]
if let selectedTemplate {
extra["media_type"] = selectedTemplate.mediaType
extra["template_id"] = selectedTemplate.id
}
if isVoice, let selectedVoiceTemplate {
extra["media_type"] = "voice"
extra["template_id"] = selectedVoiceTemplate.id
}
let imageUpload: Observable<String>
if selectedTemplate == nil {
guard let image = selectedImage, let jpeg = compressedPigeonJPEG(from: image) else {
DLToast.show(text: "请先选择一张图片")
return
}
imageUpload = UploadService.upload(jpeg, kind: .jpeg, scene: "pigeon")
} else {
imageUpload = .just("")
}
let voiceUpload: Observable<String>
if isVoice, selectedVoiceTemplate == nil {
guard let recordURL, let voiceData = try? Data(contentsOf: recordURL) else {
DLToast.show(text: "请先完成录音")
return
}
voiceUpload = UploadService.upload(voiceData, kind: .wav, scene: "pigeon")
} else {
voiceUpload = .just("")
}
DLToast.showLoading()
Observable.zip(imageUpload, voiceUpload)
.flatMap { [weak self] bgImg, voiceId -> Observable<ResponseModel> in
guard let self else { return .empty() }
let msg: String
if isVoice {
msg = voiceId
} else {
msg = caption
}
return PigeonService.send(
groupKey: self.activeGroupKey,
toUsers: self.selectedMembers.map(\.user_id),
msgType: msgType,
msg: msg,
bgImg: bgImg,
extra: extra.isEmpty ? nil : extra
)
}
.subscribe(onNext: { [weak self] _ in
DLToast.dismiss()
guard let self else { return }
self.selectedTemplate = nil
self.selectedVoiceTemplate = nil
self.selectedImage = nil
self.rootView.clearImageDraft()
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)
}
}