diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 8a034d7..9fa8c4a 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -36,6 +36,8 @@ export struct BridgeGameContainer { private cancels: Array<() => void> = []; /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ @State private subgameUrl: string = ''; + /** 当前子游戏目录名(switchGame 记录,供 setupSubgame 给能力上下文定位资源,如音效)。 */ + private currentSubgameDir: string = ''; /** 子游戏资源下载中(按需下载期间显示启动页同款进度条)。 */ @State private subgameDownloading: boolean = false; /** 子游戏下载进度(0~100)。 */ @@ -97,12 +99,14 @@ export struct BridgeGameContainer { if (this.slotL === undefined) { this.slotL = new WebSlot('lobby', this.controllerL, true, this.uploadServer); } - this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot()); + // 大厅内容目录 = gamestart(gamehall):音效等按此定位 /gamehall/assets/wav/* + this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot(), this.config!.getLocal().gamestart); } private setupSubgame(): void { this.ensureDeps(); - this.slotS?.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot()); + // 子游戏内容目录 = 当前进入的子游戏目录(switchGame 时记录),音效定位 //assets/wav/* + this.slotS?.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot(), this.currentSubgameDir); } private activeSlot(): WebSlot | undefined { @@ -186,6 +190,7 @@ export struct BridgeGameContainer { const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), '1'); AppDataInjector.inject(subDir, values); this.slotL?.deactivate(); + this.currentSubgameDir = d.dir; // 供 setupSubgame 注入能力上下文(音效资源定位) this.slotS = new WebSlot('subgame', new webview.WebviewController(), true, this.uploadServer); this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖 this.subgamePainted = false; // 子游戏首帧前用启动图盖住,避免 下载完→子游戏上屏 间白屏 diff --git a/entry/src/main/ets/web/WebSlot.ets b/entry/src/main/ets/web/WebSlot.ets index baecd42..15256ce 100644 --- a/entry/src/main/ets/web/WebSlot.ets +++ b/entry/src/main/ets/web/WebSlot.ets @@ -46,8 +46,10 @@ export class WebSlot { return this.active; } - /** onControllerAttached:建桥 + 经组装根注册能力(每槽独立一套)。 */ - setup(hostCtx: common.UIAbilityContext, config: ConfigManager, kv: KvStore, resourceRoot: string): void { + /** onControllerAttached:建桥 + 经组装根注册能力(每槽独立一套)。 + * gameContentDir:本槽加载内容目录名(大厅 gamestart / 子游戏目录),供音效等按槽定位资源。 */ + setup(hostCtx: common.UIAbilityContext, config: ConfigManager, kv: KvStore, resourceRoot: string, + gameContentDir: string): void { try { this.controller.setPathAllowingUniversalAccess([resourceRoot]); } catch (e) { @@ -65,6 +67,7 @@ export class WebSlot { config, kv, uploadServer: this.uploadServer, + gameContentDir, log: Logger.tag(`Cap-${this.role}`), captureCanvas: (canvasId: string): Promise => this.captureCanvas(canvasId), }; diff --git a/feature_capabilities/src/main/ets/core/CapabilityProvider.ets b/feature_capabilities/src/main/ets/core/CapabilityProvider.ets index 578ef1a..e7374fc 100644 --- a/feature_capabilities/src/main/ets/core/CapabilityProvider.ets +++ b/feature_capabilities/src/main/ets/core/CapabilityProvider.ets @@ -21,6 +21,12 @@ export interface CapabilityContext { kv: KvStore; /** 本机上传端点(截图分享用,M3)。 */ uploadServer: LocalUploadServer; + /** + * 本槽当前加载内容的目录名(urlpath 下的子目录):大厅槽 = gamestart(gamehall)、 + * 子游戏槽 = 子游戏目录名。音效路径 `//assets/wav/` 依赖它 + * (对齐 Android:webviewActivity 用 gamestart、NewwebviewActivity 用 gamedirectory)。 + */ + gameContentDir: string; /** 通用日志。 */ log: Logger; /** diff --git a/feature_capabilities/src/main/ets/providers/AudioProvider.ets b/feature_capabilities/src/main/ets/providers/AudioProvider.ets index d89a050..c615c21 100644 --- a/feature_capabilities/src/main/ets/providers/AudioProvider.ets +++ b/feature_capabilities/src/main/ets/providers/AudioProvider.ets @@ -1,20 +1,30 @@ /** * 音频能力(契约 §8.7/§9/§10.6,T-M3-10)。@kit.MediaKit AVPlayer。 - * - mediaTypeAudio:data={audiourl,type,user},播放语音消息 → gameui_play_voice/gameui_stop_voice(user 透传)。 - * - srcIsloop:data={src,isloop},播放本地 wav 音效/背景乐(isloop 0一次/>0循环/<0停止)。 + * - mediaTypeAudio:data={audiourl,type,user},播放语音消息(远程URL) → gameui_play_voice/gameui_stop_voice(user 透传)。 + * - srcIsloop:data={src,isloop},播放本地音效/背景乐(isloop 0一次/>0循环/<0停止该 src)。 * - voicePlaying:data="1" 开 / 其他 静音(总开关)。 * - prepareaudio:按住说话录音 →(上传七牛)→ getaudiourl。 * - * ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploader(token 走服务端,见其注释), - * 但"按住说话"的原生触摸起止手势与麦克风权限需与容器触摸集成(原 Android 在 WebView 上做手势), - * 且需七牛服务端 token 端点——此三者就绪前 prepareaudio 为安全空实现(不录、不卡死)。见 TODO。 + * 🔴 本地音效播放要点(对齐 Android webviewActivity/NewwebviewActivity.playwav 行为,内部实现自由): + * - **路径按"本槽内容目录"**:`//assets/wav/`(大厅 gamehall / 子游戏其目录)。 + * 源由 CapabilityContext.gameContentDir 注入(Android webviewActivity 用 gamestart、Newwebview 用 gamedirectory)。 + * - **AVPlayer 不接受 `file://` 本地路径**(报 401 "not fd:// or network address"):本地音效必须用 `fdSrc`(打开 fd), + * 远程语音(mediaTypeAudio)是网络地址才可直接用 `url`。这是早期"H5 完全没声"的根因。 + * - **并发不互斥**:循环 BGM 每个 src 一个播放器(loopPlayers map,复刻 Android map);一次性音效 + * 各自独立瞬时播放器(oneShots,播完自释)。互不打断、不杀 BGM(复刻 Android SoundPool 并发一次性 + MediaPlayer 循环)。 + * - isloop<0:停止该 src 的循环(Android 语义"停止背景音乐")。 + * + * ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploader(token 走服务端),但"按住说话"的原生触摸 + * 起止手势与麦克风权限需与容器触摸集成,且需七牛服务端 token 端点——三者就绪前为安全空实现(不录、不卡死)。 */ import { media } from '@kit.MediaKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { common } from '@kit.AbilityKit'; +import { fileIo } from '@kit.CoreFileKit'; import { BridgeController } from 'feature_bridge'; import { InboundHandlers, OutboundHandlers, MediaTypeAudioReq, SrcIsLoopReq } from 'contracts'; import { ConfigManager, ResourceManager } from 'domain_resource'; +import { FileSystem } from 'platform'; import { Logger } from 'common'; import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider'; @@ -24,8 +34,15 @@ export class AudioProvider implements CapabilityProvider { private bridge: BridgeController | undefined = undefined; private context: common.UIAbilityContext | undefined = undefined; private config: ConfigManager | undefined = undefined; + /** 本槽内容目录(大厅 gamehall / 子游戏目录),音效路径用。 */ + private gameContentDir: string = ''; private voicePlayer: media.AVPlayer | undefined = undefined; - private sfxPlayer: media.AVPlayer | undefined = undefined; + /** 循环 BGM:src → 播放器(每 src 一个,复刻 Android map)。 */ + private loopPlayers: Map = new Map(); + /** 一次性音效:各自独立瞬时播放器,播完自释(并发不互斥)。 */ + private oneShots: Set = new Set(); + /** 播放器 → 其打开的本地 fd(释放时关闭,防泄漏)。 */ + private fds: Map = new Map(); private muted: boolean = false; private disposed: boolean = false; @@ -33,6 +50,7 @@ export class AudioProvider implements CapabilityProvider { this.bridge = bridge; this.context = ctx.uiAbilityContext; this.config = ctx.config; + this.gameContentDir = ctx.gameContentDir; bridge.registerHandler(InboundHandlers.MediaTypeAudio, (data: string, _cb: (resp: string) => void) => { this.playVoiceMessage(data); }); @@ -49,19 +67,19 @@ export class AudioProvider implements CapabilityProvider { }); } - /** 槽去激活:停语音/音效播放(非激活端不出声,M5 §7.5)。H5 再激活后按需重触发。 */ + /** 槽去激活:停语音 + 全部音效/背景乐(非激活端不出声,M5 §7.5)。H5 再激活后按需重触发。 */ onBackground(): void { this.releaseVoice(); - this.releaseSfx(); + this.releaseAllSfx(); } onDestroy(): void { this.disposed = true; this.releaseVoice(); - this.releaseSfx(); + this.releaseAllSfx(); } - /** 播放语音消息(远程 url),上报 gameui_play_voice/gameui_stop_voice。 */ + /** 播放语音消息(远程 url,AVPlayer.url 接受网络地址),上报 gameui_play_voice/gameui_stop_voice。 */ private playVoiceMessage(data: string): void { let req: MediaTypeAudioReq; try { @@ -103,7 +121,7 @@ export class AudioProvider implements CapabilityProvider { }).catch((e: BusinessError) => this.log.w(`createAVPlayer(voice) failed: ${e.message}`)); } - /** 播放本地 wav 音效/背景乐:/assets/wav/。isloop<0 停止。 */ + /** 播放本地音效/背景乐://assets/wav/。isloop<0 停止该 src。 */ private playSfx(data: string): void { let req: SrcIsLoopReq; try { @@ -112,8 +130,14 @@ export class AudioProvider implements CapabilityProvider { this.log.w(`srcIsloop bad json: ${(e as Error).message}`); return; } - if (req.isloop < 0) { - this.releaseSfx(); + const src: string = req.src; + if (src === undefined || src === '') { + return; + } + // isloop 归一(H5 可能传字符串/数字):<0 停止 / >0 循环 / 0 一次。 + const isloop: number = Number(req.isloop); + if (isloop < 0) { + this.stopLoop(src); return; } const ctx = this.context; @@ -122,16 +146,38 @@ export class AudioProvider implements CapabilityProvider { return; } const local = cfg.getLocal(); - const wavPath: string = - `${ResourceManager.resourceRoot(ctx)}/${local.gamedir}/${local.gamestart}/assets/wav/${req.src}`; - const loop: boolean = req.isloop > 0; - this.releaseSfx(); + const dir: string = this.gameContentDir !== '' ? this.gameContentDir : local.gamestart; + const wavPath: string = `${ResourceManager.resourceRoot(ctx)}/${local.gamedir}/${dir}/assets/wav/${src}`; + if (!FileSystem.exists(wavPath)) { + this.log.w(`sfx file missing: ${wavPath}`); + return; + } + if (isloop > 0) { + this.stopLoop(src); // 同 src 重复触发:先停旧的再起新循环(复刻 Android reset+restart) + this.spawn(wavPath, true, (p: media.AVPlayer) => this.loopPlayers.set(src, p)); + } else { + this.spawn(wavPath, false, (p: media.AVPlayer) => this.oneShots.add(p)); + } + } + + /** 用 fdSrc 起一个播放器(loop 决定循环),onCreated 把它登记到对应集合。 */ + private spawn(wavPath: string, loop: boolean, onCreated: (p: media.AVPlayer) => void): void { + let file: fileIo.File; + try { + file = fileIo.openSync(wavPath, fileIo.OpenMode.READ_ONLY); + } catch (e) { + this.log.w(`open sfx failed: ${(e as Error).message}`); + return; + } + const fd: number = file.fd; media.createAVPlayer().then((player: media.AVPlayer) => { if (this.disposed) { player.release(); + this.safeCloseFd(fd); return; } - this.sfxPlayer = player; + this.fds.set(player, fd); + onCreated(player); player.on('stateChange', (state: string) => { if (state === 'initialized') { player.prepare(); @@ -141,25 +187,38 @@ export class AudioProvider implements CapabilityProvider { player.play(); } else if (state === 'completed') { if (!loop) { - this.releaseSfx(); + this.disposePlayer(player); // 一次性音效播完即释(含关 fd、移出集合) } } }); player.on('error', (e: BusinessError) => { this.log.w(`sfx play error: ${e.code} ${e.message}`); - this.releaseSfx(); + this.disposePlayer(player); }); - player.url = `file://${wavPath}`; - }).catch((e: BusinessError) => this.log.w(`createAVPlayer(sfx) failed: ${e.message}`)); + player.fdSrc = { fd } as media.AVFileDescriptor; + }).catch((e: BusinessError) => { + this.log.w(`createAVPlayer(sfx) failed: ${e.message}`); + this.safeCloseFd(fd); + }); } - /** 语音总开关:静音/取消静音,作用于当前两个播放器。 */ + /** 停止并释放某 src 的循环 BGM。 */ + private stopLoop(src: string): void { + const p = this.loopPlayers.get(src); + if (p !== undefined) { + this.loopPlayers.delete(src); + this.releasePlayer(p); + } + } + + /** 语音总开关:静音/取消静音,作用于当前全部播放器。 */ private setMuted(muted: boolean): void { this.muted = muted; const vol: number = muted ? 0 : 1; try { this.voicePlayer?.setVolume(vol); - this.sfxPlayer?.setVolume(vol); + this.loopPlayers.forEach((p: media.AVPlayer) => p.setVolume(vol)); + this.oneShots.forEach((p: media.AVPlayer) => p.setVolume(vol)); } catch (e) { this.log.w(`setVolume failed: ${(e as Error).message}`); } @@ -173,11 +232,49 @@ export class AudioProvider implements CapabilityProvider { } } - private releaseSfx(): void { - const p = this.sfxPlayer; - this.sfxPlayer = undefined; - if (p !== undefined) { - p.release().catch((e: BusinessError) => this.log.w(`release sfx failed: ${e.message}`)); + /** 释放全部音效(循环 + 一次性)。 */ + private releaseAllSfx(): void { + this.loopPlayers.forEach((p: media.AVPlayer) => this.releasePlayer(p)); + this.loopPlayers.clear(); + this.oneShots.forEach((p: media.AVPlayer) => this.releasePlayer(p)); + this.oneShots.clear(); + } + + /** 从集合移除并释放一个播放器(一次性播完/出错时用)。 */ + private disposePlayer(player: media.AVPlayer): void { + let loopKey: string | undefined = undefined; + this.loopPlayers.forEach((p: media.AVPlayer, k: string) => { + if (p === player) { + loopKey = k; + } + }); + if (loopKey !== undefined) { + this.loopPlayers.delete(loopKey); + } + this.oneShots.delete(player); + this.releasePlayer(player); + } + + /** 释放播放器并关闭其 fd(不动集合,调用方负责移除登记)。 */ + private releasePlayer(player: media.AVPlayer): void { + const fd = this.fds.get(player); + this.fds.delete(player); + player.release() + .then(() => this.safeCloseFd(fd)) + .catch((e: BusinessError) => { + this.log.w(`release sfx failed: ${e.message}`); + this.safeCloseFd(fd); + }); + } + + private safeCloseFd(fd: number | undefined): void { + if (fd === undefined) { + return; + } + try { + fileIo.closeSync(fd); + } catch (e) { + this.log.w(`close fd failed: ${(e as Error).message}`); } } }