M3(批5): AudioProvider 播放 + 七牛上传器(@qiniu/upload)

依赖:ohpm i @qiniu/upload@1.0.2(platform)

platform QiniuUploader:UploadFile.fromPath + key + createMultipartUploadV2Task(context,file,{tokenProvider})。
  🔴 token 走 tokenProvider(服务端用 AK/SK 生成 uploadToken(bucket)),AK/SK 不入端——纠正原 Android 硬编码 AK/SK

T-M3-10 AudioProvider(@kit.MediaKit AVPlayer):
  - mediaTypeAudio:播放语音 url → gameui_play_voice/gameui_stop_voice(user 透传)
  - srcIsloop:播放本地 wav(<gameDir>/assets/wav/<src>,isloop 0一次/>0循环/<0停止)
  - voicePlaying:静音总开关 setVolume
  - prepareaudio:录音分档(QiniuUploader 链路就绪;按住说话手势+麦克风+七牛服务端 token 三依赖未就绪前安全空实现)

bucket/域名/key 规则对齐原工程(bucket gameauio、回放 http://gameaudio.daoqi88.cn/<key>,由服务端 token 与 AudioProvider 接入时落地)
devecocli build 通过(CompileArkTS 编译七牛/音频无误)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 20:14:02 +08:00
parent 829f267966
commit 4a8524bcbe
10 changed files with 261 additions and 7 deletions
+1
View File
@@ -21,6 +21,7 @@ export { ShakeProvider } from './src/main/ets/providers/ShakeProvider';
export { ScanProvider } from './src/main/ets/providers/ScanProvider';
export { CameraProvider } from './src/main/ets/providers/CameraProvider';
export { LocationProvider } from './src/main/ets/providers/LocationProvider';
export { AudioProvider } from './src/main/ets/providers/AudioProvider';
// 微信 SDK 封装(EntryAbility 处理回调 want 需用)
export { WeChatApi, WX_APP_ID } from './src/main/ets/wx/WeChatApi';
+10 -1
View File
@@ -6,6 +6,7 @@
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2",
"@tencent/wechat_open_sdk@^1.0.19": "@tencent/wechat_open_sdk@1.0.19",
"common@../common": "common@../common",
"contracts@../contracts": "contracts@../contracts",
@@ -14,6 +15,13 @@
"platform@../platform": "platform@../platform"
},
"packages": {
"@qiniu/upload@1.0.2": {
"name": "@qiniu/upload",
"version": "1.0.2",
"integrity": "sha512-LWUgUSXo7KSHZ9DaTTso7D7t5VUJqLNu3vPkEdhVNSnjbajqeK8X4yIuWKp9MqCHCyB9KjHSvliRrt4A0ukQ5A==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@qiniu/upload/-/upload-1.0.2.har",
"registryType": "ohpm"
},
"@tencent/wechat_open_sdk@1.0.19": {
"name": "@tencent/wechat_open_sdk",
"version": "1.0.19",
@@ -64,7 +72,8 @@
"registryType": "local",
"dependencies": {
"contracts": "file:../contracts",
"common": "file:../common"
"common": "file:../common",
"@qiniu/upload": "^1.0.2"
}
}
}
@@ -0,0 +1,167 @@
/**
* 音频能力(契约 §8.7/§9/§10.6T-M3-10)。@kit.MediaKit AVPlayer。
* - mediaTypeAudiodata={audiourl,type,user},播放语音消息 → gameui_play_voice/gameui_stop_voice(user 透传)。
* - srcIsloopdata={src,isloop},播放本地 wav 音效/背景乐(isloop 0一次/>0循环/<0停止)。
* - voicePlayingdata="1" 开 / 其他 静音(总开关)。
* - prepareaudio:按住说话录音 →(上传七牛)→ getaudiourl。
*
* ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploadertoken 走服务端,见其注释),
* 但"按住说话"的原生触摸起止手势与麦克风权限需与容器触摸集成(原 Android 在 WebView 上做手势),
* 且需七牛服务端 token 端点——此三者就绪前 prepareaudio 为安全空实现(不录、不卡死)。见 TODO。
*/
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { BridgeController } from 'feature_bridge';
import { InboundHandlers, OutboundHandlers, MediaTypeAudioReq, SrcIsLoopReq } from 'contracts';
import { ConfigManager, ResourceManager } from 'domain_resource';
import { Logger } from 'common';
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
export class AudioProvider implements CapabilityProvider {
readonly name: string = 'audio';
private readonly log: Logger = Logger.tag('AudioProvider');
private bridge: BridgeController | undefined = undefined;
private context: common.UIAbilityContext | undefined = undefined;
private config: ConfigManager | undefined = undefined;
private voicePlayer: media.AVPlayer | undefined = undefined;
private sfxPlayer: media.AVPlayer | undefined = undefined;
private muted: boolean = false;
register(bridge: BridgeController, ctx: CapabilityContext): void {
this.bridge = bridge;
this.context = ctx.uiAbilityContext;
this.config = ctx.config;
bridge.registerHandler(InboundHandlers.MediaTypeAudio, (data: string, _cb: (resp: string) => void) => {
this.playVoiceMessage(data);
});
bridge.registerHandler(InboundHandlers.SrcIsLoop, (data: string, _cb: (resp: string) => void) => {
this.playSfx(data);
});
bridge.registerHandler(InboundHandlers.VoicePlaying, (data: string, _cb: (resp: string) => void) => {
this.setMuted(data !== '1');
});
bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => {
// TODO(M3 录音):按住说话录音(AVRecorder)→ platform QiniuUploader.upload(token 走服务端) →
// callHandler(getaudiourl, {audiourl,time,filepath})。需容器触摸手势(起/止)、麦克风权限、七牛服务端 token 端点。
this.log.i('prepareaudio: recording requires touch-gesture + mic perm + qiniu server token (stub)');
});
}
onDestroy(): void {
this.releaseVoice();
this.releaseSfx();
}
/** 播放语音消息(远程 url),上报 gameui_play_voice/gameui_stop_voice。 */
private playVoiceMessage(data: string): void {
let req: MediaTypeAudioReq;
try {
req = JSON.parse(data) as MediaTypeAudioReq;
} catch (e) {
this.log.w(`mediaTypeAudio bad json: ${(e as Error).message}`);
return;
}
if (req.audiourl === '') {
return;
}
const user: string = req.user;
this.releaseVoice();
media.createAVPlayer().then((player: media.AVPlayer) => {
this.voicePlayer = player;
player.on('stateChange', (state: string) => {
if (state === 'initialized') {
player.prepare();
} else if (state === 'prepared') {
player.loop = false;
player.setVolume(this.muted ? 0 : 1);
player.play();
this.bridge?.callHandler(OutboundHandlers.GameUiPlayVoice, user);
} else if (state === 'completed') {
this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, user);
this.releaseVoice();
}
});
player.on('error', (e: BusinessError) => {
this.log.w(`voice play error: ${e.code} ${e.message}`);
this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, user);
this.releaseVoice();
});
player.url = req.audiourl;
}).catch((e: BusinessError) => this.log.w(`createAVPlayer(voice) failed: ${e.message}`));
}
/** 播放本地 wav 音效/背景乐:<gameDir>/assets/wav/<src>。isloop<0 停止。 */
private playSfx(data: string): void {
let req: SrcIsLoopReq;
try {
req = JSON.parse(data) as SrcIsLoopReq;
} catch (e) {
this.log.w(`srcIsloop bad json: ${(e as Error).message}`);
return;
}
if (req.isloop < 0) {
this.releaseSfx();
return;
}
const ctx = this.context;
const cfg = this.config;
if (ctx === undefined || cfg === undefined) {
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();
media.createAVPlayer().then((player: media.AVPlayer) => {
this.sfxPlayer = player;
player.on('stateChange', (state: string) => {
if (state === 'initialized') {
player.prepare();
} else if (state === 'prepared') {
player.loop = loop;
player.setVolume(this.muted ? 0 : 1);
player.play();
} else if (state === 'completed') {
if (!loop) {
this.releaseSfx();
}
}
});
player.on('error', (e: BusinessError) => {
this.log.w(`sfx play error: ${e.code} ${e.message}`);
this.releaseSfx();
});
player.url = `file://${wavPath}`;
}).catch((e: BusinessError) => this.log.w(`createAVPlayer(sfx) failed: ${e.message}`));
}
/** 语音总开关:静音/取消静音,作用于当前两个播放器。 */
private setMuted(muted: boolean): void {
this.muted = muted;
const vol: number = muted ? 0 : 1;
try {
this.voicePlayer?.setVolume(vol);
this.sfxPlayer?.setVolume(vol);
} catch (e) {
this.log.w(`setVolume failed: ${(e as Error).message}`);
}
}
private releaseVoice(): void {
const p = this.voicePlayer;
this.voicePlayer = undefined;
if (p !== undefined) {
p.release().catch((e: BusinessError) => this.log.w(`release voice failed: ${e.message}`));
}
}
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}`));
}
}
}