Design §8.1:补 AudioKit 4 项 handler 完整实现骨架(方案 A 示范)
H5↔Native 接口实现层缺口补全启动(用户选 A:分散到各 module 章节补)。
本 commit 作为风格示范,后续 commit 按相同模板补 DeviceKit / Location /
Shake / OpenurlTitleData 共 11 项 handler。
§8.1 末尾新增两小节:
§8.1.1 — H5 audio handlers 实现骨架(185 行)
- AudioHandlers struct 集中注册 4 项 handler,便于发现 / 单测
- 【3】srcIsloop 入参解析 src/isloop → SandboxPaths.lobbyAssets/wav
+ AudioPlayer.playOnce / loopBackground / stopBackground 三态
+ responseCallback "Response from srcIsloop"
- 【4】prepareaudio 权限三态分支(denied → bridge.call alertMessage
而非原生弹窗,契约 §6.2 ifauth 语义)+ recordAndUpload
+ getaudiourl 反向 callback(time 字段必须 string,契约硬约束)
- 【5】mediaTypeAudio voiceCenter 总开关短路 + 下载 → AMR→WAV
→ playVoice with onStart/onEnd 桥到 gameui_play_voice/
gameui_stop_voice;失败也补 stop_voice 避免 H5 UI 卡"播放中"
- 【6】voicePlaying actor VoiceCenter 隔离 isEnabled 状态
§8.1.2 — 与原 msext 的差异(不要照抄的部分)
- 录音 UI / 上传后端 / 状态共享 / 总开关 / 路径拼接 5 维度对照表
骨架级别(结构 + 关键调用 + 注释 + 契约引用),不是产品代码 — 实施
Phase 2 时按此骨架落地真实代码。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c95e80df9c
commit
92298b657e
@@ -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
|
### 8.2 ShareKit
|
||||||
|
|
||||||
策略模式分发,新增平台只加一个 conformance:
|
策略模式分发,新增平台只加一个 conformance:
|
||||||
|
|||||||
Reference in New Issue
Block a user