Files
youle_app_ios_v2/ylgamehall/Source/Audio/VoicePlayer.swift
T
2026-06-30 17:40:54 +08:00

135 lines
5.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// VoicePlayer.swift
// ylgamehall
//
// 远端语音播放器:AVAudioPlayer 包装 + 反向 callback `gameui_stop_voice` 触发点。
//
// 契约:docs/H5-Native-Contract.md §3.1 5mediaTypeAudio + §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 方法签名是 nonisolatedAVFoundation 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 ?? "<nil>", privacy: .public) success=\(success, privacy: .public)")
if let user { onFinish?(user) }
}
}