diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index 1baa5d2..31bfd21 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1629,6 +1629,191 @@ public actor AudioRecorder { } ``` +#### 8.1.1 H5 audio handlers 实现骨架 + +把契约 §3.1 §B 的 4 项 audio handler(【3】–【6】)粘到 AudioKit 上的胶水代码。注册集中到 `AudioHandlers` 一个 struct,便于发现 / 单测。 + +```swift +// Source/Bridge/Handlers/AudioHandlers.swift +import Foundation + +public struct AudioHandlers { + + let bridge: BridgeProtocol + let player: AudioPlayer + let recorder: AudioRecorder + let voiceCenter: VoiceCenter // 8.1.1 引入,控制远端语音的播放总开关 + + public func register() { + bridge.register("srcIsloop", handler: srcIsloop) + bridge.register("prepareaudio", handler: prepareAudio) + bridge.register("mediaTypeAudio", handler: mediaTypeAudio) + bridge.register("voicePlaying", handler: voicePlaying) + } + + // MARK: - 【3】 srcIsloop — 本地音频播放 + // + // 契约 §3.1 [3]: + // 入参 src : string 音频文件名,路径 = Library/Caches/{gamedir}/{gamestart}/assets/wav/{src} + // 入参 isloop : int 0 单次 / 1 循环 / -1 停同类循环 + // responseCallback: "Response from srcIsloop" + // + // 与原 msext 的差异(不破契约): + // - 原项目用 NSURL fileURLWithPath: 拼接 + AVAudioPlayer 同步初始化; + // 新外壳通过 SandboxPaths 统一管理,所有路径在一处可校验 + // - 原项目 backgroundType 用全局 NSString *;新外壳把状态封装在 + // @MainActor 的 AudioPlayer 内部,避免跨线程访问竞态 + + private func srcIsloop(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from srcIsloop")) } + guard let obj = data?.asObject, + let src = obj["src"]?.asString, + let isloop = obj["isloop"]?.asInt + else { + Log.warn("srcIsloop: bad payload \(String(describing: data))") + return + } + + let url = SandboxPaths.lobbyAssets + .appendingPathComponent("wav") + .appendingPathComponent(src) + + await MainActor.run { + switch isloop { + case 0: player.playOnce(url) + case 1: player.loopBackground(url, type: src) + case -1: player.stopBackground(type: src) + default: Log.warn("srcIsloop: unexpected isloop=\(isloop)") + } + } + } + + // MARK: - 【4】 prepareaudio — 启动麦克风录音 + // + // 契约 §3.1 [4]: + // 入参 : 忽略 + // 副作用 : 权限检查 → recordAndUpload → getaudiourl callback + // responseCallback: "Response from prepareaudio" + // + // 权限态参考契约 §6.2「ifauth」三态语义: + // 0 denied/restricted → 弹 alert 并放弃 + // 1 not determined → AudioRecorder 内部触发系统询问 + // 2 authorized → 直接录 + // + // 与原 msext 的差异: + // - 原项目 VoiceRecorderBaseVC 是 UI 录音控件,含 cancel/redo 按钮, + // 与 H5 的录音 UI 重复。新外壳走"无 UI 录音"路径:H5 自己画按钮, + // 原生只负责拿到 AVAudioRecorder 数据 → 转码 → 上传,不弹任何原生界面 + // - 上传走七牛 SDK(已在 ADR-006 选型);旧 PostFile 接口不再支持 + + private func prepareAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async { + cb?(.string("Response from prepareaudio")) + + switch await Permissions.microphone() { + case .denied, .restricted: + // 契约要求:通过 H5 alert 提示(不是原生弹窗),文案 = "{gamehallname}需要访问您的麦克风" + bridge.call("alertMessage", + data: .string("\(BundleConfig.shared.appDisplayName)需要访问您的麦克风"), + callback: nil) + return + case .notDetermined, .authorized: + break + } + + do { + let result = try await recorder.recordAndUpload() + // 契约 §3.2 [9]:getaudiourl({audiourl, time}) + bridge.call("getaudiourl", + data: .object([ + "audiourl": .string(result.audioUrl), + "time": .string(String(result.time)) // ⚠️ 字符串,非 number + ]), + callback: nil) + } catch { + Log.error("prepareaudio failed: \(error)") + } + } + + // MARK: - 【5】 mediaTypeAudio — 远程语音回放 + // + // 契约 §3.1 [5]: + // 入参 audiourl: string 远端 AMR URL + // 入参 user : string 发声方用户 ID + // responseCallback: "Response from mediaTypeAudio" + // 开始播放: gameui_play_voice(user) (§3.2 [7]) + // 播放结束: gameui_stop_voice(user) (§3.2 [8]) + // + // 总开关由 voicePlaying 控制(【6】),关闭态直接静默不播 + + private func mediaTypeAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from mediaTypeAudio")) } + + guard let obj = data?.asObject, + let urlStr = obj["audiourl"]?.asString, + let user = obj["user"]?.asString, + let remote = URL(string: urlStr) + else { + Log.warn("mediaTypeAudio: bad payload") + return + } + + guard await voiceCenter.isEnabled else { + Log.info("mediaTypeAudio: voicePlaying off, skip user=\(user)") + return + } + + do { + let amr = try await AudioDownloader.fetchToCache(remote) + let wav = try await VoiceCoder.amrToWavAsync(amr) + await MainActor.run { + player.playVoice(wav, + onStart: { [weak bridge] in + bridge?.call("gameui_play_voice", data: .string(user), callback: nil) + }, + onEnd: { [weak bridge] in + bridge?.call("gameui_stop_voice", data: .string(user), callback: nil) + }) + } + } catch { + Log.error("mediaTypeAudio failed for user=\(user): \(error)") + // 失败也要补 stop_voice,避免 H5 端 UI 永远停在"播放中"状态 + bridge.call("gameui_stop_voice", data: .string(user), callback: nil) + } + } + + // MARK: - 【6】 voicePlaying — 语音播放总开关 + // + // 契约 §3.1 [6]: + // 入参: int 1=允许 / 其他=静默 + // responseCallback: "Response from voicePlaying" + // + // VoiceCenter 持有 actor 隔离的 bool 状态,mediaTypeAudio 每次读它 + // 决定要不要走完整的下载→转码→播放链路 + + private func voicePlaying(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from voicePlaying")) } + let on = (data?.asInt ?? 0) == 1 + await voiceCenter.setEnabled(on) + } +} + +/// 远端语音播放总开关(actor 隔离,跨 bridge handler 共享) +public actor VoiceCenter { + public private(set) var isEnabled: Bool = true + public func setEnabled(_ on: Bool) { isEnabled = on } +} +``` + +#### 8.1.2 与原 msext 的差异(不要照抄的部分) + +| 维度 | msext 现状(不要照抄) | 新外壳决策 | +|------|--------------------|----------| +| 录音 UI | `VoiceRecorderBaseVC` 原生录音控件 + cancel/redo 按钮 | 无原生 UI;H5 自己画按钮,原生只录 + 转 + 传 | +| 上传 | 旧 `gameapi.0791ts.cn/api/UpLoad/PostFile` 直传后台 | 七牛 SDK(ADR-006 已选型) | +| 状态共享 | `static NSString *backgroundType` 全局变量 | `@MainActor` AudioPlayer 内部状态 | +| 总开关 | `static BOOL voicePlaying` | `actor VoiceCenter` 隔离 | +| 路径拼接 | 各处 `NSURL fileURLWithPath:` 散落 | 统一走 `SandboxPaths.lobbyAssets` | + ### 8.2 ShareKit 策略模式分发,新增平台只加一个 conformance: