From 173bf79a7419469cab09e44c729b8123fd2a6f04 Mon Sep 17 00:00:00 2001 From: joywayer Date: Wed, 24 Jun 2026 01:31:52 +0800 Subject: [PATCH] =?UTF-8?q?3.D=EF=BC=9AVoicePlayer=20=E8=BF=9C=E7=AB=AF?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=94=BE=E5=99=A8=20+=20gameui=5Fst?= =?UTF-8?q?op=5Fvoice=20=E8=A7=A6=E5=8F=91=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支撑 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 错误。 --- ylgamehall/Source/Audio/VoicePlayer.swift | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 ylgamehall/Source/Audio/VoicePlayer.swift 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) + } +}