diff --git a/ylgamehall/Source/Audio/VoicePlayer.swift b/ylgamehall/Source/Audio/VoicePlayer.swift new file mode 100644 index 0000000..0999a25 --- /dev/null +++ b/ylgamehall/Source/Audio/VoicePlayer.swift @@ -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) + } +}