624 lines
22 KiB
Swift
624 lines
22 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?.applySelectedVoiceTemplate(template)
|
|
}
|
|
rootView.deleteImageButton.addTarget(self, action: #selector(deleteImageDraft), 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 applySelectedVoiceTemplate(_ template: PigeonVoiceTemplate?) {
|
|
if let template, template.audioData == nil {
|
|
selectedVoiceTemplate = nil
|
|
cleanupAudio(deleteFile: true)
|
|
rootView.setVoiceTemplate(nil)
|
|
DLToast.show(text: "模板资源不可用")
|
|
return
|
|
}
|
|
|
|
cleanupAudio(deleteFile: true)
|
|
selectedVoiceTemplate = template
|
|
rootView.setVoiceTemplate(template)
|
|
}
|
|
|
|
private func showCameraDenied() {
|
|
Permission.openAppSetting(
|
|
title: "无法使用相机",
|
|
message: "请在系统设置中允许访问相机"
|
|
)
|
|
}
|
|
|
|
@objc private func deleteImageDraft() {
|
|
selectedTemplate = nil
|
|
selectedImage = nil
|
|
rootView.clearImageDraft()
|
|
}
|
|
|
|
@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()
|
|
selectedVoiceTemplate = nil
|
|
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() {
|
|
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: AVAudioPlayer
|
|
if let template = selectedVoiceTemplate,
|
|
let data = template.audioData {
|
|
player = try AVAudioPlayer(data: data)
|
|
} else if let recordURL {
|
|
player = try AVAudioPlayer(contentsOf: recordURL)
|
|
} else {
|
|
return
|
|
}
|
|
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,
|
|
totalDuration: player.duration,
|
|
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 let template = selectedVoiceTemplate {
|
|
rootView?.setVoiceDuration(
|
|
0,
|
|
totalDuration: template.duration,
|
|
hasRecording: template.audioData != nil
|
|
)
|
|
} else 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 isImageTemplate = selectedTemplate != nil
|
|
let isVoiceTemplate = isVoice && selectedVoiceTemplate != nil
|
|
let caption = rootView.captionText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var extra: [String: Any] = [:]
|
|
if let selectedTemplate {
|
|
extra["image_template_id"] = selectedTemplate.id
|
|
}
|
|
if isVoice, let selectedVoiceTemplate {
|
|
extra["voice_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 bgImgId: Int64
|
|
if isImageTemplate {
|
|
bgImgId = 0
|
|
} else if let uploadedId = Int64(bgImg) {
|
|
bgImgId = uploadedId
|
|
} else {
|
|
let error = NSError(
|
|
domain: "PigeonMessage",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "背景图片上传结果异常"]
|
|
)
|
|
return .error(error)
|
|
}
|
|
let msg: String
|
|
if isVoice {
|
|
msg = isVoiceTemplate ? "0" : voiceId
|
|
} else {
|
|
msg = caption
|
|
}
|
|
return PigeonService.send(
|
|
groupKey: self.activeGroupKey,
|
|
toUsers: self.selectedMembers.map(\.user_id),
|
|
msgType: msgType,
|
|
msg: msg,
|
|
bgImg: bgImgId,
|
|
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)
|
|
if let template = selectedVoiceTemplate {
|
|
rootView.setVoiceDuration(
|
|
0,
|
|
totalDuration: template.duration,
|
|
hasRecording: template.audioData != nil
|
|
)
|
|
} else {
|
|
rootView.setVoiceDuration(recordedDuration, hasRecording: recordedDuration >= 1)
|
|
}
|
|
}
|
|
}
|