Files
youle_app_ios_v2/ylgamehall/Source/Audio/AudioPlayer.swift
T
joywayerandClaude Opus 4.7 0602b9d4b6 Phase 3.A:srcIsloop 本地音频 + 3.B/3.C/3.D handler stub
3.A 本地音频完整落地(与 msext NewRootVC initJSdata 等价):

  Source/Audio/AudioPlayer.swift(@MainActor 状态机):
    - playOnce(url) 单次按钮音;buttonPlayers 数组保活池避免多次点击
      互相打断(duration + 0.5s 后清理)
    - loopBackground(url, type:) 循环背景音;numberOfLoops = -1 +
      backgroundType 记录用于同名停止
    - stopBackground(type:) 同名校验后才停止(msext 同款语义)

  Source/Bridge/Handlers/LocalAudioHandler.swift:
    - 注册 srcIsloop handler(@MainActor enum)
    - 入参 src + isloop,按 0/1/-1 分支调 AudioPlayer.shared 对应方法
    - audioFileURL 路径:{Caches}/{gamedir}/{gamestart}/assets/wav/{src}
      与 msext NewRootVC 路径约定 1:1(H5 zip 内 assets/wav 子目录)
    - audioFileURL 标 nonisolated 因 SandboxPaths 是 nonisolated 静态
    - cb 字面 "Response from srcIsloop" 严格保持

  Source/Bridge/Handlers/RemoteAudioHandler.swift(3.B/3.C/3.D stub):
    - prepareaudio + mediaTypeAudio 仅 cb 维持契约,无真实业务
    - 待 Phase 3.B(opencore-amr .a 接入)+ 3.C(七牛 SPM + token)+
      3.D(远端播放)完整后升级
    - 按 CLAUDE.md 原则 A:H5 调啥就得有人接,即使是 stub

WebContainerViewController.registerBridgeHandlers 接入:
  - LocalAudioHandler.register(完整实现)
  - RemoteAudioHandler.register(stub)

Plan §5 Phase 3.1 勾选;3.2 真机验证留待 H5 提供音频文件后做。

Verification-Checklist.md 新增 Phase 3 章节:
  - H. 本地音频(E.14-E.16 srcIsloop 三个 isloop 值)
  - I. prepareaudio / mediaTypeAudio stub(E.17 E.18)

BuildProject 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 19:11:34 +08:00

86 lines
3.4 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.
//
// AudioPlayer.swift
// ylgamehall
//
// 本地音频播放(srcIsloop 支撑):背景音循环 + 单次按钮音。
//
// 契约:docs/H5-Native-Contract.md §3.1 3srcIsloop
// 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)")
}
}