// // 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) } }