Files
youle_app_ios_v2/ylgamehall/Source/Audio/VoicePlayer.swift
T
joywayer 173bf79a74 3.D:VoicePlayer 远端语音播放器 + gameui_stop_voice 触发点
支撑 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 错误。
2026-06-24 01:31:52 +08:00

118 lines
4.8 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 成员变量;本类把同款状态封装为单例。关键行为:
//
// - `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 **不**触发 onFinishdaoqi 没有主动 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:438voicePlaying=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 方法签名是 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 }
player = nil
Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)")
onFinish?(lastUserId)
}
}