Files
youle_app_ios_v2/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift
T
joywayer 97749ac177 3.E 修复:首次启动权限请求与浮层 touchObserver 死锁
## 问题

首次启动 app,H5 按住麦克风按钮触发 prepareaudio:
1. 主动调 AVAudioSession.requestRecordPermission 弹系统权限对话框
2. 用户为了点"允许"必须松手
3. 松手被 RecordingAwareWindow.touchObserver 立刻捕获 → recorder.stop()
4. 浮层瞬间关闭,录音失败
5. 用户感受:"按一次没反应,再点也没用"

## 根因

我之前用 `requestRecordPermission` 主动请求权限(会同步弹系统对话框),
与 daoqi `FuncPublic.ifauth`(只读 `AVCaptureDevice.authorizationStatus`,
不主动请求)行为不一致。主动请求 + 按住状态 + touchObserver 全局监听
三者形成死锁。

## 修复

改用只读状态 + 三分支处理:

- `.denied / .restricted`:弹 Alert + return(对齐 daoqi)
- `.notDetermined`(首次):**主动弹权限对话框但不启动录音浮层**。
  用户做完选择后 return,不触发 cb。第二次按录音按钮时走 .authorized
  分支正常录音
- `.authorized`:直接启动录音浮层

## 与 daoqi 行为对比

daoqi 在 .notDetermined 时 ifauth 返回 1(继续启动录音),但实测此路径
也有体验问题——浮层弹出 + 系统对话框遮挡 + 用户松手立刻触发 touchEnded
→ 录音空文件 → 转码失败 → silent return。最终用户体验也是"按一次没
反应"。

新外壳的修复让首次行为更明确(不启动录音 = 不会有空文件上传),与 daoqi
真实终态(不触发 getaudiourl)等价,UX 等价(首次启动需按两次)。

## H5 契约影响

无。"不触发 cb" 与 daoqi 转码失败 silent return 等价;H5 收不到
getaudiourl 反向 callback 时本来就要兼容(daoqi 各类失败路径都不触发 cb)。

## 验证

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

345 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
// 死锁)。用户做完选择 / 第二次按录音按钮时走 .authorized 分支正常录音。
//
// ⚠️ daoqi ifauth 在 .notDetermined 时返回 1(继续录音),但 daoqi 用
// NotificationCenter 全局监听,行为可能与新外壳的 touchObserver 不一致;
// 经实测新外壳走 daoqi 老路径会"按一次没反应"——改用先请求权限再 return
// 的策略,等价于 "第一次按录音按钮 = 触发权限请求;第二次按 = 正常录音"。
_ = await withCheckedContinuation { cont in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
cont.resume(returning: granted)
}
}
// 无论用户允许/拒绝都不触发 cb(H5 端会因没收到 cb 知道本次录音未发生)
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)
}
}