Files
youle_app_ios_v2/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift
T
joywayer 530309c258 3.E 修复:notDetermined 路径触发 cb 让 H5 释放业务锁
## 问题

用户首次点击录音 → 弹权限对话框 → 用户允许 → H5 整体无法再点击。

## 根因

我上次修复让 .notDetermined 分支 return 时不触发 cb,违反 daoqi 行为:
daoqi NewRootVC.m:322 在 ifauth==1(NotDetermined)路径下走录音流程并
**立即触发 responseCallback**。

H5 业务在 callHandler('prepareaudio') 调用后通常会上"录音中"业务锁
(加触摸遮罩、禁用按钮等),等收到 cb 才解锁。系统权限对话框已经把
H5 的 in-flight touch 打断为 touchcancel,H5 业务的 touchcancel 处理
可能不完善 → 业务锁不释放 → 整个 H5 后续无法点击。

## 修复

.notDetermined 分支权限请求完成后**仍然触发 cb**:

```swift
case .notDetermined:
    _ = await requestRecordPermission()
    callback?(.string("Response from prepareaudio"))  // ← 触发 cb
    return
```

H5 收到 cb 释放业务锁,整个 H5 恢复可点击。本次实际未录音 → 无后续
getaudiourl,H5 按"无 getaudiourl 的失败路径"走(与 daoqi 转码失败
silent 路径等价)。用户第二次按录音按钮走 .authorized 分支正常录音。

## 验证

BuildProject 通过 0 错误。
2026-06-24 01:56:52 +08:00

351 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RemoteAudioHandler.swift
// ylgamehall
//
// H5 → Native handlersprepareaudio / 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
//
// ## mediaTypeAudioPhase 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: 录音浮层挂载的宿主 VClobby = 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 打断为 touchcancelH5 业务的 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)
}
}