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>
This commit is contained in:
joywayer
2026-06-22 19:11:34 +08:00
co-authored by Claude Opus 4.7
parent b2ee1b38eb
commit 0602b9d4b6
6 changed files with 210 additions and 9 deletions
+85
View File
@@ -0,0 +1,85 @@
//
// 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)")
}
}