// // RemoteAudioHandler.swift // ylgamehall // // H5 → Native handlers:prepareaudio / mediaTypeAudio // 契约:docs/H5-Native-Contract.md §3.1 [4][5] + §3.2 [7][8][9] // // ## prepareaudio 完整链路(Phase 3.E) // // H5 调用 → 权限检查 → // ├─ 拒绝:弹 Alert,不触发 cb(对齐 daoqi NewRootVC.m:314-319) // └─ 同意:RecordingPresenter 启动录音浮层 + 立即回 cb // ↓ 用户松手 / 60s // 录音完成 (wavURL) // ↓ // AVAudioPlayer 读 duration → round 取整秒 // ↓ // 生成 amr 文件名(大厅 / 子游戏不同) // ↓ // AMRCodec.wavToAmr → 七牛上传 // ↓ // 反向 callback getaudiourl + 子游戏额外 recordSuccess // // ## mediaTypeAudio(Phase 3.F 实现,本提交仍为 stub) // // 本文件仍保留 stub;Phase 3.F 会替换为完整链路(下载 amr → amrToWav → // VoicePlayer.play + gameui_play_voice / gameui_stop_voice)。 // import Foundation import AVFoundation import UIKit import os.log public enum RemoteAudioHandler { private static let log = Logger(subsystem: "ylgamehall", category: "RemoteAudio") /// 注册 prepareaudio / mediaTypeAudio。 /// - Parameters: /// - bridge: 桥 /// - hostVC: 录音浮层挂载的宿主 VC(lobby = WebContainerViewController, /// subGame = SubGameViewController)。每次录音重新求值,避免捕获过时引用 /// - isSubGame: 是否子游戏。子游戏录音成功后额外触发 recordSuccess /// 反向 callback(对齐 daoqi gameController.m:2310;大厅 NewRootVC 不触发) public static func register( on bridge: any BridgeProtocol, hostVC: @escaping @MainActor @Sendable () -> UIViewController?, isSubGame: Bool ) { // 【4】prepareaudio — 完整实现(Phase 3.E) // cb: "Response from prepareaudio"(仅录音启动成功后触发; // 权限拒绝 / 启动失败时不触发,对齐 daoqi 行为) bridge.register("prepareaudio") { _, callback in Task { @MainActor in await handlePrepareAudio( bridge: bridge, hostVC: hostVC, isSubGame: isSubGame, callback: callback ) } } // 【5】mediaTypeAudio — 完整实现(Phase 3.F) // cb: "Response from mediaTypeAudio"(在下载+转码完成后触发,对齐 daoqi // 同步阻塞下载语义;下载失败时不触发 cb) bridge.register("mediaTypeAudio") { data, callback in Task { @MainActor in await handleMediaTypeAudio( data: data, bridge: bridge, callback: callback ) } } } // MARK: - prepareaudio 处理(MainActor) @MainActor private static func handlePrepareAudio( bridge: any BridgeProtocol, hostVC: @MainActor @Sendable () -> UIViewController?, isSubGame: Bool, callback: BridgeCallback? ) async { // 1. 麦克风权限**只读**状态检查(对齐 daoqi FuncPublic.m:1144-1170 ifauth 实现)。 // 主动请求权限(requestRecordPermission)会立即弹出 iOS 系统对话框; // 在 H5 按住状态下弹系统对话框 → 用户必须松手点"允许" → // RecordingAwareWindow.touchObserver 捕获松手立刻 stop → 浮层瞬间关闭, // 用户体验异常(首次启动 app 录音永远失败)。 let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .denied, .restricted: // 明确拒绝 → 弹 Alert + 不触发 cb(对齐 daoqi NewRootVC.m:314-319) showMicrophonePermissionAlert(on: hostVC()) return case .notDetermined: // 首次启动:弹系统权限对话框,**不**启动录音浮层(避免与 touchObserver // 死锁——touchObserver 会捕获用户松手点"允许"的动作立刻 stop 录音)。 // 用户做完选择 / 第二次按录音按钮时走 .authorized 分支正常录音。 _ = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { granted in cont.resume(returning: granted) } } // ⚠️ 必须触发 cb(对齐 daoqi ifauth==1 路径行为,daoqi NewRootVC.m:322 // 在 [recorderVC beginRecordByFileName:] 后总是 responseCallback)。 // // 不触发 cb 的后果:H5 业务在 callHandler 调用后通常会上"录音中"业务锁 // (加触摸遮罩、禁用按钮等),等收到 cb 才解锁。系统权限对话框已经把 // H5 的 in-flight touch 打断为 touchcancel,H5 业务的 touchcancel 处理 // 可能不完善 → 业务锁不释放 → 整个 H5 后续无法点击。 // // 触发 cb 让 H5 释放业务锁,即使本次实际未录音也无害(H5 会按"无 // getaudiourl"的失败路径走,与 daoqi 转码失败 silent 路径等价)。 callback?(.string("Response from prepareaudio")) return case .authorized: break // 继续启动录音 @unknown default: return } // 2. 启动录音浮层 guard let vc = hostVC() else { log.error("prepareaudio: no host VC") return } let fileName = VoiceRecorder.currentTimeString() do { try RecordingPresenter.start(on: vc, fileName: fileName) { wavURL, _ in // 录音完成回调(@MainActor)。后续转码 / 上传是 IO 重活,挪到 // 后台 Task 避免阻塞主线程 Task.detached(priority: .userInitiated) { await processCompletedRecording( wavURL: wavURL, bridge: bridge, isSubGame: isSubGame ) } } } catch { log.error("prepareaudio start failed: \(error.localizedDescription, privacy: .public)") return // 启动失败不触发 cb(对齐 daoqi handler 内 return 行为) } // 3. 启动成功,立即触发 callback(对齐 daoqi NewRootVC.m:322 // [recorderVC beginRecordByFileName:..] 之后 responseCallback) callback?(.string("Response from prepareaudio")) } // MARK: - 录音完成后处理(后台 Task) private static func processCompletedRecording( wavURL: URL, bridge: any BridgeProtocol, isSubGame: Bool ) async { // 1. 读 wav duration → round 取整秒(对齐 daoqi NewRootVC.m:1891-1893 // AVAudioPlayer.duration + round 行为) let durationSec: Int do { let probe = try AVAudioPlayer(contentsOf: wavURL) durationSec = Int(probe.duration.rounded()) } catch { log.error("read wav duration failed: \(error.localizedDescription, privacy: .public)") return } // 2. 生成上传文件名(大厅 vs 子游戏命名规则不同) let amrFileName = uploadFileName(isSubGame: isSubGame) let amrURL = wavURL.deletingLastPathComponent().appendingPathComponent(amrFileName) // 3. WAV → AMR 转码 do { try AMRCodec.wavToAmr(wavURL, to: amrURL) } catch { log.error("wav→amr failed: \(error.localizedDescription, privacy: .public)") return } // 4. 七牛上传(key = amrURL.lastPathComponent,由 QiniuUploader 取) let uploaded: UploadedFile do { uploaded = try await QiniuUploader.shared.upload(amrURL, timeSec: durationSec) } catch { log.error("qiniu upload failed: \(error.localizedDescription, privacy: .public)") return } // 5. 反向 callback(在 MainActor) await MainActor.run { // getaudiourl // ⚠️ time 是 String(%ld 整秒),不是 number — 对齐 daoqi // NewRootVC.m:1923 / gameController.m:2305 bridge.call( "getaudiourl", data: .object([ "audiourl": .string(uploaded.fileUrl), "time": .string(String(durationSec)) ]), callback: nil ) log.debug("getaudiourl audiourl=\(uploaded.fileUrl, privacy: .public) time=\(durationSec, privacy: .public)") // 子游戏额外 recordSuccess(对齐 daoqi gameController.m:2310; // 大厅 NewRootVC 无此 callHandler) if isSubGame { bridge.call( "recordSuccess", data: .object([ "fileUrl": .string(uploaded.fileUrl), "fileName": .string(uploaded.fileName), "fileKey": .string(uploaded.fileKey) ]), callback: nil ) log.debug("recordSuccess fileKey=\(uploaded.fileKey, privacy: .public)") } } } // MARK: - 工具 /// 上传文件名(同时是七牛 key、本地 .amr 文件名): /// 大厅:`yyyyMMddHHmmss%08X.amr` (无下划线,对齐 daoqi NewRootVC.m:1908) /// 子游戏:`yyyyMMddHHmmss_%08X.amr` (有下划线,对齐 daoqi gameController.m:2269) private static func uploadFileName(isSubGame: Bool) -> String { let timestamp = VoiceRecorder.currentTimeString() let random = String(format: "%08X", arc4random()) let separator = isSubGame ? "_" : "" return "\(timestamp)\(separator)\(random).amr" } // MARK: - mediaTypeAudio 处理(MainActor) @MainActor private static func handleMediaTypeAudio( data: BridgeData?, bridge: any BridgeProtocol, callback: BridgeCallback? ) async { // 1. 解析入参(只有 audiourl + user 两个字段,对齐 daoqi // NewRootVC.m:326-328 / gameController.m:435-437) guard let obj = data?.asObject, let urlString = obj["audiourl"]?.asString, let user = obj["user"]?.asString, let downloadURL = URL(string: urlString) else { log.error("mediaTypeAudio: bad params \(String(describing: data), privacy: .public)") return // 参数不全 → 不触发 cb(对齐 daoqi 入参空时走到 file 不存在 return) } // 2. 记录 lastUserId(无论是否实际播放都更新,对齐 daoqi // `self.User_id = userid;` 总是执行的行为) VoicePlayer.shared.recordIncomingUser(user) // 3. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge; // [weak bridge] 让 bridge 释放后 onFinish 自动失效) VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in Task { @MainActor in bridge?.call( "gameui_stop_voice", data: .string(finishedUser), callback: nil ) } } // 4. 异步下载 AMR let amrData: Data do { let (d, _) = try await URLSession.shared.data(from: downloadURL) amrData = d } catch { log.error("mediaTypeAudio download failed: \(error.localizedDescription, privacy: .public)") return // 下载失败 → 不触发 cb(对齐 daoqi NewRootVC.m:335-337 // `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`) } // 5. 写到沙盒 .amr 临时文件 let tempName = VoiceRecorder.currentTimeString() let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr") try? FileManager.default.removeItem(at: amrLocal) do { try amrData.write(to: amrLocal) } catch { log.error("mediaTypeAudio write amr failed: \(error.localizedDescription, privacy: .public)") return } // 6. AMR → WAV 转码 // daoqi 文件名约定:tempName + "_AmrToWav.wav"(NewRootVC.m:341) let wavLocal = docs .appendingPathComponent("\(tempName)_AmrToWav") .appendingPathExtension("wav") try? FileManager.default.removeItem(at: wavLocal) let transcoded: Bool do { try AMRCodec.amrToWav(amrLocal, to: wavLocal) transcoded = true } catch { // 转码失败:log,**但 cb 仍触发**(对齐 daoqi NewRootVC.m:355-357 // 失败仅 NSLog 不影响 responseCallback 触发) log.error("mediaTypeAudio amr→wav failed: \(error.localizedDescription, privacy: .public)") transcoded = false } // 7. voicePlaying 总开关 + 实际播放 + gameui_play_voice // (仅转码成功 + 开关 enabled 时触发,对齐 daoqi // NewRootVC.m:343-353 if (canbofang) { play + callHandler }) let canPlay = await VoiceCenter.shared.isEnabled if transcoded, canPlay { do { try VoicePlayer.shared.play(wavLocal) // gameui_play_voice 数据是 user 字符串(非 dict) bridge.call( "gameui_play_voice", data: .string(user), callback: nil ) log.debug("gameui_play_voice user=\(user, privacy: .public)") } catch { log.error("VoicePlayer.play failed: \(error.localizedDescription, privacy: .public)") } } // 8. 触发 mediaTypeAudio cb(对齐 daoqi NewRootVC.m:357,**总是**触发 // 即使转码失败 / canbofang=NO 都触发;仅下载失败的 return 路径不触发) callback?(.string("Response from mediaTypeAudio")) } @MainActor private static func showMicrophonePermissionAlert(on hostVC: UIViewController?) { // 对齐 daoqi NewRootVC.m:316 文案("{gamehallname}需要访问您的麦克风...") let alert = UIAlertController( title: nil, message: "需要访问您的麦克风,请启用麦克风-设置/隐私/麦克风!", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "确定", style: .default)) hostVC?.present(alert, animated: true) } }