diff --git a/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift index a84c23a..da46ae8 100644 --- a/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift @@ -2,39 +2,216 @@ // RemoteAudioHandler.swift // ylgamehall // -// H5 → Native handler stub:prepareaudio / mediaTypeAudio +// H5 → Native handlers:prepareaudio / mediaTypeAudio // 契约:docs/H5-Native-Contract.md §3.1 [4][5] + §3.2 [7][8][9] // -// Phase 3.A 阶段为 stub(仅 cb 维持契约,让 H5 启动不报 "no handler"); -// Phase 3.B AMR 转码 + Phase 3.C 录音上传 + Phase 3.D mediaTypeAudio 真实播放 -// 完整接入后升级为真实现。 +// ## prepareaudio 完整链路(Phase 3.E) // -// 按 CLAUDE.md 原则 A 第一准则:H5 调啥就得有人接,即使没真实业务也要注册。 +// 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 { - public static func register(on bridge: any BridgeProtocol) { - // 【4】prepareaudio — 启动麦克风录音 - // 入参忽略;cb: "Response from prepareaudio" - // 完整实现(Phase 3.C):权限检查 + AVAudioRecorder + WAV→AMR + 七牛上传 + - // 反向 callback getaudiourl({audiourl, time}) + 子游戏额外 recordSuccess - // stub 仅 cb 维持契约 + 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 - callback?(.string("Response from prepareaudio")) + Task { @MainActor in + await handlePrepareAudio( + bridge: bridge, + hostVC: hostVC, + isSubGame: isSubGame, + callback: callback + ) + } } - // 【5】mediaTypeAudio — 远程语音回放 - // 入参 audiourl: string;user: string - // cb: "Response from mediaTypeAudio" - // 完整实现(Phase 3.D):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV + - // AVAudioPlayer 播放 + 反向 callback gameui_play_voice(user) / + // 【5】mediaTypeAudio — Phase 3.F stub(仅 cb 维持契约) + // 完整实现(Phase 3.F):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV + + // VoicePlayer 播放 + 反向 callback gameui_play_voice(user) / // gameui_stop_voice(user) - // stub 仅 cb 维持契约(不下载、不播放) bridge.register("mediaTypeAudio") { _, callback in callback?(.string("Response from mediaTypeAudio")) } } + + // MARK: - prepareaudio 处理(MainActor) + + @MainActor + private static func handlePrepareAudio( + bridge: any BridgeProtocol, + hostVC: @MainActor @Sendable () -> UIViewController?, + isSubGame: Bool, + callback: BridgeCallback? + ) async { + // 1. 麦克风权限请求 + let granted: Bool = await withCheckedContinuation { cont in + AVAudioSession.sharedInstance().requestRecordPermission { ok in + cont.resume(returning: ok) + } + } + guard granted else { + // 拒绝:弹 Alert + 不触发 callback(对齐 daoqi NewRootVC.m:314-319) + showMicrophonePermissionAlert(on: hostVC()) + 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" + } + + @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) + } } diff --git a/ylgamehall/Source/Network/QiniuUploader.swift b/ylgamehall/Source/Network/QiniuUploader.swift index f1ec5ca..8e950e2 100644 --- a/ylgamehall/Source/Network/QiniuUploader.swift +++ b/ylgamehall/Source/Network/QiniuUploader.swift @@ -49,10 +49,11 @@ public actor QiniuUploader { throw QiniuUploadError.fileNotFound(localFile.path) } - // 生成 key:`{recordingDirectory}{uuid}.{ext}`(与 msext QiniuManager.m:55-60 等价) - let uuid = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") - let ext = localFile.pathExtension - let key = QiniuConfig.recordingDirectory + uuid + (ext.isEmpty ? "" : ".\(ext)") + // 七牛 key 直接用本地文件名(调用方负责命名)。对齐 daoqi 录音文件命名规则: + // - 大厅:yyyyMMddHHmmss%08X.amr(NewRootVC.m:1908,无下划线) + // - 子游戏:yyyyMMddHHmmss_%08X.amr(gameController.m:2269,有下划线) + // H5 通过 audiourl 转发给对端,对端按文件名规律假设——必须严格对齐。 + let key = QiniuConfig.recordingDirectory + localFile.lastPathComponent // 客户端自签 token(参 QiniuTokenSigner / msext QiniuManager 同款算法) let token = QiniuTokenSigner.uploadToken(key: key) diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index d30c19a..c9a5cd4 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -103,7 +103,13 @@ public final class SubGameViewController: UIViewController { .deletingLastPathComponent() } ) - RemoteAudioHandler.register(on: bridge) + // 子游戏 isSubGame=true:录音上传成功后额外触发 recordSuccess 反向 callback + // (对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler) + RemoteAudioHandler.register( + on: bridge, + hostVC: { [weak self] in self }, + isSubGame: true + ) AccreditLoginHandler.register(on: bridge) FriendsShareHandler.register(on: bridge) diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 27556df..937b0cc 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -84,7 +84,14 @@ public final class WebContainerViewController: UIViewController { on: bridge, assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() } ) // §3.1 [3]srcIsloop 完整实现 - RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub + // §3.1 [4]prepareaudio 完整实现 / [5]mediaTypeAudio Phase 3.F stub + // 大厅 isSubGame=false:录音上传成功后仅触发 getaudiourl,不触发 recordSuccess + // (对齐 daoqi NewRootVC 无 recordSuccess callHandler 的行为) + RemoteAudioHandler.register( + on: bridge, + hostVC: { [weak self] in self }, + isSubGame: false + ) // Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级) AccreditLoginHandler.register(on: bridge) // §3.1 [1]Phase 4.E 完整微信 SDK