// // 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 成员变量,覆盖式播放时直接 `voicePlayer = [[AVAudioPlayer alloc]...]` // 替换 ivar,没有触发任何停旧 player 的回调;自然结束时再用最新的 // `self.User_id`(已被新一条消息覆盖)调 stopradio → 发给 H5 的 // `gameui_stop_voice` payload 实际是**新一条消息的 user_id**,导致旧消息 // 对应的 H5 语音动画永远停不掉。 // // 本类把 user 与 player 一一绑定(`currentUser`): // - 覆盖式 play:在替换 player 之前主动 stop 旧 player 并触发 // `onFinish(currentUser)`,让 H5 收到正确的 `gameui_stop_voice(被打断 user)` // - 自然结束 / 解码失败:用 `currentUser` 而非任何后写入的"最近 user" // 触发 onFinish,保证停的是真正结束那条 // - 主动 stop():不触发 onFinish(保留 daoqi 语义;本项目目前也没有外部 // 主动 stop 调用路径) // 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? /// 当前正在播放的语音对应的 user。与 `player` 一一绑定:`player != nil` 时一定有值, /// `player == nil` 时为 nil。覆盖式 play / didFinishPlaying / decodeError 都用这个 /// 值触发 onFinish,确保 `gameui_stop_voice` payload 与真正结束 / 被中断的语音对应。 private var currentUser: String? /// 播放完成 / 被中断 / 解码失败统一回调。 /// mediaTypeAudio handler 注册此 closure → 反向调 H5 `gameui_stop_voice`。 /// payload 始终是与刚结束 / 被打断 player 绑定的 user(不是入参中最新的 user)。 public var onFinish: ((String) -> Void)? private override init() { super.init() } /// 播放 wav。若当前已有正在播放的 player,先 stop 旧 player 并触发 /// `onFinish(被打断的 user)`,让 H5 把旧动画停掉;再启动新 player。 /// - Throws: AVAudioPlayer 创建 / 启动失败 public func play(_ wav: URL, user: String) throws { // 1. 若已有 player 正在播放,主动通知"被中断",确保 H5 把旧动画停掉。 // delegate 先置 nil 防止旧 player 的 stop / dealloc 后续异步触发 // didFinishPlaying 又再发一次 onFinish。 if let old = player { old.delegate = nil old.stop() player = nil if let interrupted = currentUser { currentUser = nil Self.log.debug("interrupted user=\(interrupted, privacy: .public)") onFinish?(interrupted) } else { currentUser = nil } } // 2. 创建并启动新 player let p = try AVAudioPlayer(contentsOf: wav) p.delegate = self guard p.prepareToPlay(), p.play() else { throw PlayerError.startFailed } player = p currentUser = user Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(user, privacy: .public)") } /// 主动停止当前播放(不触发 onFinish)。 /// 外部清理使用(如 H5 BackGameData 退出子游戏时); /// 业务正常路径下不会主动调,由 didFinishPlaying 自然完成。 public func stop() { player?.delegate = nil player?.stop() player = nil currentUser = 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 } let user = currentUser player = nil currentUser = nil Self.log.debug("didFinish user=\(user ?? "", privacy: .public) success=\(success, privacy: .public)") if let user { onFinish?(user) } } }