3.D:VoicePlayer 远端语音播放器 + gameui_stop_voice 触发点

支撑 Phase 3.F mediaTypeAudio handler 的播放子链路。

## 行为对齐原项目

- `lastUserId`:mediaTypeAudio 入参 user 字段,每次进来都更新(无论
  voicePlaying=1/0、无论是否实际播放),对齐 daoqi gameController.m:438
  `self.User_id=userid;` 总是执行的行为
- `gameui_stop_voice` payload 用 `lastUserId`,**不**是 didFinishPlaying
  传入的 player 关联值(多条消息连发时 stop 发最新 user,与 daoqi 行为
  一致——这是原项目的"特征",不是 bug)
- 覆盖式 play:前一条还在播时收到第二条直接替换 player(对齐 daoqi
  `voicePlayer = [[AVAudioPlayer alloc]...]` ARC 覆盖行为)
- 主动 stop 不触发 onFinish(daoqi 没有主动 stop 路径)

## Swift 6 并发处理

AVAudioPlayerDelegate 方法签名 nonisolated;AVAudioPlayer 是
non-Sendable 不能跨 actor 传递。用 ObjectIdentifier(Sendable)传递
身份,handleFinish 在 MainActor 内重新查 self.player 比对身份。

防御覆盖式 play 时旧 player stop 触发延迟 delegate:仅当 didFinish
的 player 与当前 self.player 同身份时才清理 + 触发 onFinish。

## 验证

BuildProject 通过 0 错误。
This commit is contained in:
joywayer
2026-06-24 01:31:52 +08:00
parent c398a8dd58
commit 173bf79a74
+117
View File
@@ -0,0 +1,117 @@
//
// VoicePlayer.swift
// ylgamehall
//
// 远端语音播放器:AVAudioPlayer 包装 + 反向 callback `gameui_stop_voice` 触发点。
//
// 契约:docs/H5-Native-Contract.md §3.1 [5]mediaTypeAudio + §3.2 [7][8]
// gameui_play_voice / gameui_stop_voice
//
// ## 与原项目的行为对齐
//
// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作
// 为 VC 成员变量;本类把同款状态封装为单例。关键行为:
//
// - `lastUserId`:mediaTypeAudio 入参 user 字段(每次 mediaTypeAudio 进来
// 都更新,无论是否真的播放——对齐 daoqi gameController.m:438
// `self.User_id=userid;` 在 canbofang=NO 时也会执行)
// - `gameui_stop_voice` payload 用 `lastUserId`,不是 didFinishPlaying 传
// 入的 player 关联值(对齐 daoqi stopradio 用 self.User_id 的行为;多条
// 消息连发时 gameui_stop_voice 发最新 user,**这是 daoqi 的行为**)
// - 覆盖式 play:前一条还在播时收到第二条,直接覆盖 player(让旧的被
// ARC/ARC-like 释放);对齐 daoqi `voicePlayer = [[AVAudioPlayer alloc]...]`
// 赋值行为
// - 主动 stop **不**触发 onFinish(daoqi 没有主动 stop 路径,只有
// audioPlayerDidFinishPlaying 触发 stopradio)
//
import AVFoundation
import os.log
public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
public static let shared = VoicePlayer()
private static let log = Logger(subsystem: "ylgamehall", category: "VoicePlayer")
private var player: AVAudioPlayer?
/// 最近一次 mediaTypeAudio 入参中的 user 字段。
/// 对齐 daoqi self.User_id 行为(成员变量,每次 mediaTypeAudio 进来都更新)。
public private(set) var lastUserId: String = ""
/// 播放完成回调(AVAudioPlayer didFinishPlaying 触发,成功/失败都触发)。
/// mediaTypeAudio handler 注册此 closure 触发反向调用 gameui_stop_voice。
/// payload 是 `lastUserId`(不是 player 关联的 user,对齐 daoqi 行为)。
public var onFinish: ((String) -> Void)?
private override init() { super.init() }
/// 记录 mediaTypeAudio 入参的 user,**无论是否实际播放都要调**。
/// 对齐 daoqi gameController.m:438(voicePlaying=0 时仍执行 `self.User_id=userid`)。
public func recordIncomingUser(_ user: String) {
lastUserId = user
}
/// 播放 wav。若已有正在播放的 player,直接覆盖(对齐 daoqi 行为)。
/// 调用方应在调用前调 `recordIncomingUser(user)` 更新 lastUserId。
/// - Throws: AVAudioPlayer 创建/启动失败
public func play(_ wav: URL) throws {
let p = try AVAudioPlayer(contentsOf: wav)
p.delegate = self
guard p.prepareToPlay(), p.play() else {
throw PlayerError.startFailed
}
player = p
Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(self.lastUserId, privacy: .public)")
}
/// 主动停止当前播放(不触发 onFinish)。
/// 外部清理使用(如 H5 BackGameData 退出子游戏时);
/// 业务正常路径下不会主动调,由 didFinishPlaying 自然完成。
public func stop() {
player?.stop()
player = nil
}
public enum PlayerError: Error, Sendable {
case startFailed
}
// MARK: - AVAudioPlayerDelegate
//
// AVAudioPlayerDelegate 方法签名是 nonisolated(AVFoundation API 约束)。
// 内部访问 @MainActor 隔离的状态需要跳转回主线程。
nonisolated public func audioPlayerDidFinishPlaying(
_ player: AVAudioPlayer,
successfully flag: Bool
) {
// AVAudioPlayer 是 non-Sendable,不能直接跨 actor 边界。用 ObjectIdentifier
// (Sendable)传递身份,handleFinish 内重新查 self.player 比对。
let id = ObjectIdentifier(player)
Task { @MainActor in
self.handleFinish(playerIdentity: id, success: flag)
}
}
nonisolated public func audioPlayerDecodeErrorDidOccur(
_ player: AVAudioPlayer,
error: (any Error)?
) {
let id = ObjectIdentifier(player)
Task { @MainActor in
self.handleFinish(playerIdentity: id, success: false)
}
}
/// 处理 didFinishPlaying / decodeError。
/// 防御覆盖式 play 时旧 player stop 触发延迟 delegate:仅当 didFinish
/// 的 player 与当前 self.player 同身份时才清理 + 触发 onFinish。
private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) {
guard let current = player, ObjectIdentifier(current) == playerIdentity else { return }
player = nil
Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)")
onFinish?(lastUserId)
}
}