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 错误。
This commit is contained in:
joywayer
2026-06-24 01:31:52 +08:00
parent c398a8dd58
commit 173bf79a74
+117
View File
@@ -0,0 +1,117 @@
//
// VoicePlayer.swift
// ylgamehall
//
// AVAudioPlayer + callback `gameui_stop_voice`
//
// docs/H5-Native-Contract.md §3.1 5mediaTypeAudio + §3.2 78
// 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
// SendablehandleFinish 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)
}
}