// // AudioPlayer.swift // ylgamehall // // 本地音频播放(srcIsloop 支撑):背景音循环 + 单次按钮音。 // // 契约:docs/H5-Native-Contract.md §3.1 [3]srcIsloop // Design:§8.1 AudioKit // import AVFoundation import os.log /// 本地音频播放器(@MainActor 隔离的状态机)。 /// /// 三类音频通道: /// - `background`:循环背景音,记录 backgroundType 用于 isloop=-1 时同名停止 /// - `button`:单次按钮音(短促音效),互不抢占(每次调 playOnce 新建 player) /// /// 与原 msext NewRootVC 的 `buttunPlayer` / `backgroundPlayer` / /// `backgroundType` 静态变量等价,封装到实例避免全局可变状态。 @MainActor public final class AudioPlayer { public static let shared = AudioPlayer() private static let log = Logger(subsystem: "ylgamehall", category: "AudioPlayer") private var backgroundPlayer: AVAudioPlayer? private var backgroundType: String? /// 单次按钮音的 player 保活池:同时点多个按钮也不会互相打断 private var buttonPlayers: [AVAudioPlayer] = [] public init() {} // MARK: - srcIsloop 入口 /// 单次按钮音播放(msext `isloop == 0`)。 public func playOnce(_ url: URL) { do { let player = try AVAudioPlayer(contentsOf: url) player.numberOfLoops = 0 player.prepareToPlay() player.play() buttonPlayers.append(player) // 短促音播放完就清理(依靠 AVAudioPlayer 自身的 duration + delay 释放) let duration = player.duration Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: UInt64((duration + 0.5) * 1_000_000_000)) self?.buttonPlayers.removeAll { $0 === player } } Self.log.debug("playOnce \(url.lastPathComponent, privacy: .public) duration=\(duration, privacy: .public)s") } catch { Self.log.error("playOnce failed: \(error.localizedDescription, privacy: .public) url=\(url.path, privacy: .public)") } } /// 循环背景音(msext `isloop == 1`)。同名再次启动会替换上一份。 public func loopBackground(_ url: URL, type: String) { do { let player = try AVAudioPlayer(contentsOf: url) player.numberOfLoops = -1 // 无限循环 player.prepareToPlay() player.play() backgroundPlayer?.stop() backgroundPlayer = player backgroundType = type Self.log.debug("loopBackground type=\(type, privacy: .public) url=\(url.lastPathComponent, privacy: .public)") } catch { Self.log.error("loopBackground failed: \(error.localizedDescription, privacy: .public) url=\(url.path, privacy: .public)") } } /// 停止同名背景音(msext `isloop == -1`)。仅 type 与当前 backgroundType 匹配时停止。 public func stopBackground(type: String) { guard backgroundType == type else { Self.log.debug("stopBackground noop: requested type=\(type, privacy: .public) but current=\(self.backgroundType ?? "nil", privacy: .public)") return } backgroundPlayer?.stop() backgroundPlayer = nil backgroundType = nil Self.log.debug("stopBackground done type=\(type, privacy: .public)") } /// 无条件停止当前背景音。子游戏 `backgameData` 退出时统一调一次 /// (msext gameController.m:693-697 行为:不看 type,直接 stop + nil)。 public func stopAllBackground() { guard backgroundPlayer != nil else { return } let oldType = backgroundType ?? "nil" backgroundPlayer?.stop() backgroundPlayer = nil backgroundType = nil Self.log.debug("stopAllBackground done (was type=\(oldType, privacy: .public))") } }