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
+10 -1
View File
@@ -6,11 +6,19 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": { "specifiers": {
"@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2",
"common@../common": "common@../common", "common@../common": "common@../common",
"contracts@../contracts": "contracts@../contracts", "contracts@../contracts": "contracts@../contracts",
"platform@../platform": "platform@../platform" "platform@../platform": "platform@../platform"
}, },
"packages": { "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"
},
"common@../common": { "common@../common": {
"name": "common", "name": "common",
"version": "1.0.0", "version": "1.0.0",
@@ -33,7 +41,8 @@
"registryType": "local", "registryType": "local",
"dependencies": { "dependencies": {
"contracts": "file:../contracts", "contracts": "file:../contracts",
"common": "file:../common" "common": "file:../common",
"@qiniu/upload": "^1.0.2"
} }
} }
} }
+10 -1
View File
@@ -6,6 +6,7 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": { "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", "@tencent/wechat_open_sdk@^1.0.19": "@tencent/wechat_open_sdk@1.0.19",
"common@../common": "common@../common", "common@../common": "common@../common",
"contracts@../contracts": "contracts@../contracts", "contracts@../contracts": "contracts@../contracts",
@@ -15,6 +16,13 @@
"platform@../platform": "platform@../platform" "platform@../platform": "platform@../platform"
}, },
"packages": { "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": { "@tencent/wechat_open_sdk@1.0.19": {
"name": "@tencent/wechat_open_sdk", "name": "@tencent/wechat_open_sdk",
"version": "1.0.19", "version": "1.0.19",
@@ -79,7 +87,8 @@
"registryType": "local", "registryType": "local",
"dependencies": { "dependencies": {
"contracts": "file:../contracts", "contracts": "file:../contracts",
"common": "file:../common" "common": "file:../common",
"@qiniu/upload": "^1.0.2"
} }
} }
} }
+2 -1
View File
@@ -8,7 +8,7 @@
import { CapabilityProvider, EchoSampleProvider, RoomStubProvider, PayStubProvider, import { CapabilityProvider, EchoSampleProvider, RoomStubProvider, PayStubProvider,
VibrateProvider, ClipboardProvider, AppSystemProvider, DeviceProvider, VibrateProvider, ClipboardProvider, AppSystemProvider, DeviceProvider,
NetworkProvider, ShareProvider, LoginProvider, PhotoProvider, ShakeProvider, NetworkProvider, ShareProvider, LoginProvider, PhotoProvider, ShakeProvider,
ScanProvider, CameraProvider, LocationProvider } from 'feature_capabilities'; ScanProvider, CameraProvider, LocationProvider, AudioProvider } from 'feature_capabilities';
export function buildCapabilities(): CapabilityProvider[] { export function buildCapabilities(): CapabilityProvider[] {
return [ return [
@@ -26,6 +26,7 @@ export function buildCapabilities(): CapabilityProvider[] {
new ScanProvider(), // opensaoma → getsaomaData new ScanProvider(), // opensaoma → getsaomaData
new CameraProvider(), // opencamera → getcameraaAddress new CameraProvider(), // opencamera → getcameraaAddress
new LocationProvider(), // startlocation/getlocationinfo → getlocationinfo new LocationProvider(), // startlocation/getlocationinfo → getlocationinfo
new AudioProvider(), // mediaTypeAudio/srcIsloop/voicePlaying(播放);prepareaudio 录音分档
// —— 暂缓能力占位桩(§6.5)—— // —— 暂缓能力占位桩(§6.5)——
new RoomStubProvider(), new RoomStubProvider(),
new PayStubProvider(), new PayStubProvider(),
+1
View File
@@ -21,6 +21,7 @@ export { ShakeProvider } from './src/main/ets/providers/ShakeProvider';
export { ScanProvider } from './src/main/ets/providers/ScanProvider'; export { ScanProvider } from './src/main/ets/providers/ScanProvider';
export { CameraProvider } from './src/main/ets/providers/CameraProvider'; export { CameraProvider } from './src/main/ets/providers/CameraProvider';
export { LocationProvider } from './src/main/ets/providers/LocationProvider'; export { LocationProvider } from './src/main/ets/providers/LocationProvider';
export { AudioProvider } from './src/main/ets/providers/AudioProvider';
// 微信 SDK 封装(EntryAbility 处理回调 want 需用) // 微信 SDK 封装(EntryAbility 处理回调 want 需用)
export { WeChatApi, WX_APP_ID } from './src/main/ets/wx/WeChatApi'; export { WeChatApi, WX_APP_ID } from './src/main/ets/wx/WeChatApi';
+10 -1
View File
@@ -6,6 +6,7 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": { "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", "@tencent/wechat_open_sdk@^1.0.19": "@tencent/wechat_open_sdk@1.0.19",
"common@../common": "common@../common", "common@../common": "common@../common",
"contracts@../contracts": "contracts@../contracts", "contracts@../contracts": "contracts@../contracts",
@@ -14,6 +15,13 @@
"platform@../platform": "platform@../platform" "platform@../platform": "platform@../platform"
}, },
"packages": { "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": { "@tencent/wechat_open_sdk@1.0.19": {
"name": "@tencent/wechat_open_sdk", "name": "@tencent/wechat_open_sdk",
"version": "1.0.19", "version": "1.0.19",
@@ -64,7 +72,8 @@
"registryType": "local", "registryType": "local",
"dependencies": { "dependencies": {
"contracts": "file:../contracts", "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}`));
}
}
}
+1
View File
@@ -14,3 +14,4 @@ export { Unzipper } from './src/main/ets/zip/Unzipper';
// 权限 / 上传服务 // 权限 / 上传服务
export { PermissionGuard } from './src/main/ets/perm/PermissionGuard'; export { PermissionGuard } from './src/main/ets/perm/PermissionGuard';
export { LocalUploadServer } from './src/main/ets/upload/LocalUploadServer'; export { LocalUploadServer } from './src/main/ets/upload/LocalUploadServer';
export { QiniuUploader, QiniuTokenProvider } from './src/main/ets/upload/QiniuUploader';
+8
View File
@@ -6,10 +6,18 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": { "specifiers": {
"@qiniu/upload@^1.0.2": "@qiniu/upload@1.0.2",
"common@../common": "common@../common", "common@../common": "common@../common",
"contracts@../contracts": "contracts@../contracts" "contracts@../contracts": "contracts@../contracts"
}, },
"packages": { "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"
},
"common@../common": { "common@../common": {
"name": "common", "name": "common",
"version": "1.0.0", "version": "1.0.0",
+5 -2
View File
@@ -7,6 +7,9 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"contracts": "file:../contracts", "contracts": "file:../contracts",
"common": "file:../common" "common": "file:../common",
} "@qiniu/upload": "^1.0.2"
},
"devDependencies": {},
"dynamicDependencies": {}
} }
@@ -0,0 +1,46 @@
/**
* 七牛云对象存储上传(@qiniu/upload,契约 §10.6 录音上传)。
*
* 🔴 安全(CLAUDE.md 附录 B / §14.2):上传 token 由 **tokenProvider 异步提供**
* 应由服务端用 AK/SK 生成 uploadToken(bucket) 后下发;**AK/SK 绝不入客户端**。
* 原 Android 把 AK/SK 硬编码在端侧,本平台用 tokenProvider 模式纠正。
*/
import { common } from '@kit.AbilityKit';
import { createMultipartUploadV2Task, UploadFile, UploadConfig, UploadTask } from '@qiniu/upload';
import { Logger } from 'common';
/** token 提供器:返回上传 token 的 Promise(来源应为服务端)。 */
export type QiniuTokenProvider = () => Promise<string>;
export class QiniuUploader {
private static readonly log: Logger = Logger.tag('QiniuUploader');
/**
* 上传本地文件到七牛,资源名为 key。成功返回 true。
* @param context UIAbility 上下文
* @param localPath 本地文件路径
* @param key 七牛资源名(= 回放 URL 的路径段)
* @param tokenProvider 上传 token 提供器(服务端生成 uploadToken(bucket)
*/
static async upload(context: common.Context, localPath: string, key: string,
tokenProvider: QiniuTokenProvider): Promise<boolean> {
const file: UploadFile = UploadFile.fromPath(localPath);
file.key = key;
const config: UploadConfig = { tokenProvider };
const task: UploadTask = createMultipartUploadV2Task(context, file, config);
let failed: boolean = false;
task.onError(() => {
failed = true;
});
try {
await task.start();
} catch (e) {
QiniuUploader.log.w(`upload start failed: ${(e as Error).message}`);
return false;
}
if (failed) {
QiniuUploader.log.w(`upload failed for key=${key}`);
}
return !failed;
}
}