接入宿主生命周期暂停背景音乐

useBackgroundMusic 订阅 app.lifecycle 统一事件

宿主进后台或桌面失焦时暂停音频循环并 suspend WebAudio

宿主恢复 active 后按用户原音乐状态继续播放

更新测试和原生壳架构文档
This commit is contained in:
2026-06-18 03:39:00 +08:00
parent d261cc8d0b
commit 8d30cf65bb
5 changed files with 220 additions and 15 deletions
@@ -36,6 +36,7 @@
- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,不请求相机或麦克风权限,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` 时,主图上传和描述参考图上传优先调用 `importHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
- 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 订阅 `subscribeHostAppLifecycle()`,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`
File diff suppressed because one or more lines are too long
@@ -41,7 +41,7 @@ AI H5 sandbox
- `getHostRuntime()`:识别 `browser``wechat_mini_program``native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。
- `getHostAppearanceColorScheme()`:原生 App 宿主的受控外观查询入口。H5 可通过 `appearance.getColorScheme` 读取宿主当前 `light` / `dark` / `unknown` 配色模式;Expo 移动壳通过 React Native `Appearance.getColorScheme()` 读取系统偏好,Tauri 桌面壳通过主窗口 `theme()` 读取窗口主题。该能力只读,不改变 H5 主题,也不覆盖用户或系统偏好。
- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur 事件派发同名事件;H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。
- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur 事件派发同名事件;H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。当前 H5 背景音乐 hook 已订阅该事件,宿主进入后台或窗口失焦时会暂停音频循环,回到 active 且用户原本开启音乐时再恢复播放。
- `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`:原生 App 宿主的受控网络状态入口。Expo 移动壳通过 `expo-network` 查询并订阅真实系统网络状态;Tauri 桌面壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并在主 WebView 内监听 `online` / `offline` 注入变化事件。H5 只依赖统一的 `isConnected``isInternetReachable` 和连接类型,不直接读取平台私有网络 API。
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
+142
View File
@@ -0,0 +1,142 @@
/* @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { subscribeHostAppLifecycle } from '../services/host-bridge/hostBridge';
import { useBackgroundMusic } from './useBackgroundMusic';
vi.mock('../services/host-bridge/hostBridge', () => ({
subscribeHostAppLifecycle: vi.fn(() => () => undefined),
}));
const subscribeHostAppLifecycleMock = vi.mocked(subscribeHostAppLifecycle);
class MockAudioParam {
value = 0;
setValueAtTime = vi.fn((value: number) => {
this.value = value;
});
linearRampToValueAtTime = vi.fn((value: number) => {
this.value = value;
});
exponentialRampToValueAtTime = vi.fn((value: number) => {
this.value = value;
});
cancelScheduledValues = vi.fn();
}
class MockGainNode {
gain = new MockAudioParam();
connect = vi.fn();
}
class MockOscillatorNode {
frequency = new MockAudioParam();
detune = new MockAudioParam();
type: OscillatorType = 'sine';
connect = vi.fn();
start = vi.fn();
stop = vi.fn();
}
const audioContextInstances: MockAudioContext[] = [];
class MockAudioContext {
currentTime = 0;
destination = {};
state: AudioContextState = 'suspended';
resume = vi.fn(async () => {
this.state = 'running';
});
suspend = vi.fn(async () => {
this.state = 'suspended';
});
close = vi.fn(async () => {
this.state = 'closed';
});
createGain = vi.fn(() => new MockGainNode());
createOscillator = vi.fn(() => new MockOscillatorNode());
constructor() {
audioContextInstances.push(this);
}
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
audioContextInstances.length = 0;
Reflect.deleteProperty(window, 'AudioContext');
Reflect.deleteProperty(window, 'webkitAudioContext');
});
function installAudioContextMock() {
Object.defineProperty(window, 'AudioContext', {
configurable: true,
value: MockAudioContext,
});
}
test('background music suspends and resumes with native host lifecycle', async () => {
vi.useFakeTimers();
installAudioContextMock();
let lifecycleListener: Parameters<typeof subscribeHostAppLifecycle>[0] | null =
null;
subscribeHostAppLifecycleMock.mockImplementation((listener) => {
lifecycleListener = listener;
return () => undefined;
});
renderHook(() => useBackgroundMusic({ active: true, volume: 0.6 }));
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown'));
await Promise.resolve();
});
const context = audioContextInstances[0];
expect(context).toBeTruthy();
expect(context?.resume).toHaveBeenCalledTimes(1);
await act(async () => {
lifecycleListener?.({
state: 'background',
focused: false,
nativeState: 'background',
});
await Promise.resolve();
});
expect(context?.suspend).toHaveBeenCalledTimes(1);
await act(async () => {
lifecycleListener?.({
state: 'active',
focused: true,
nativeState: 'active',
});
await Promise.resolve();
});
expect(context?.resume).toHaveBeenCalledTimes(2);
});
test('background music keeps browser fallback behavior when native lifecycle is unsupported', async () => {
installAudioContextMock();
renderHook(() => useBackgroundMusic({ active: true, volume: 0.6 }));
await act(async () => {
window.dispatchEvent(new Event('pointerdown'));
await Promise.resolve();
});
const context = audioContextInstances[0];
expect(subscribeHostAppLifecycleMock).toHaveBeenCalledTimes(1);
expect(context?.resume).toHaveBeenCalledTimes(1);
expect(context?.suspend).not.toHaveBeenCalled();
});
+74 -12
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { subscribeHostAppLifecycle } from '../services/host-bridge/hostBridge';
type AudioWindow = Window & {
webkitAudioContext?: typeof AudioContext;
};
@@ -88,6 +90,7 @@ export function useBackgroundMusic({
const stepRef = useRef(0);
const activeRef = useRef(active);
const volumeRef = useRef(volume);
const hostLifecycleActiveRef = useRef(true);
const stopLoop = useCallback(() => {
if (loopTimerRef.current !== null) {
@@ -126,7 +129,12 @@ export function useBackgroundMusic({
const scheduleLoop = useCallback(() => {
const graph = ensureAudioGraph();
if (!graph || !activeRef.current || volumeRef.current <= 0) {
if (
!graph ||
!activeRef.current ||
!hostLifecycleActiveRef.current ||
volumeRef.current <= 0
) {
stopLoop();
return;
}
@@ -162,21 +170,46 @@ export function useBackgroundMusic({
loopTimerRef.current = window.setTimeout(scheduleLoop, 2200);
}, [ensureAudioGraph, stopLoop]);
const updateMasterVolume = useCallback((graph?: { context: AudioContext; masterGain: GainNode } | null) => {
const audioGraph = graph ?? ensureAudioGraph();
if (!audioGraph) return;
const updateMasterVolume = useCallback(
(graph?: { context: AudioContext; masterGain: GainNode } | null) => {
const audioGraph =
graph ??
(contextRef.current && masterGainRef.current
? {
context: contextRef.current,
masterGain: masterGainRef.current,
}
: null);
if (!audioGraph) return;
const targetGain = activeRef.current && volumeRef.current > 0
? Math.max(0.0001, volumeRef.current * 0.18)
: 0.0001;
const targetGain =
activeRef.current &&
hostLifecycleActiveRef.current &&
volumeRef.current > 0
? Math.max(0.0001, volumeRef.current * 0.18)
: 0.0001;
audioGraph.masterGain.gain.cancelScheduledValues(audioGraph.context.currentTime);
audioGraph.masterGain.gain.linearRampToValueAtTime(targetGain, audioGraph.context.currentTime + 0.24);
}, [ensureAudioGraph]);
audioGraph.masterGain.gain.cancelScheduledValues(
audioGraph.context.currentTime,
);
audioGraph.masterGain.gain.linearRampToValueAtTime(
targetGain,
audioGraph.context.currentTime + 0.24,
);
},
[],
);
const startPlayback = useCallback(async () => {
const graph = ensureAudioGraph();
if (!graph || !activeRef.current || volumeRef.current <= 0) return;
if (
!graph ||
!activeRef.current ||
!hostLifecycleActiveRef.current ||
volumeRef.current <= 0
) {
return;
}
if (graph.context.state === 'suspended') {
await graph.context.resume();
@@ -189,11 +222,21 @@ export function useBackgroundMusic({
}
}, [ensureAudioGraph, scheduleLoop, updateMasterVolume]);
const suspendPlaybackForHostLifecycle = useCallback(() => {
updateMasterVolume();
stopLoop();
const context = contextRef.current;
if (context?.state === 'running') {
void context.suspend().catch(() => undefined);
}
}, [stopLoop, updateMasterVolume]);
useEffect(() => {
activeRef.current = active;
volumeRef.current = volume;
if (!active || volume <= 0) {
if (!active || !hostLifecycleActiveRef.current || volume <= 0) {
updateMasterVolume();
stopLoop();
return;
@@ -214,6 +257,25 @@ export function useBackgroundMusic({
};
}, [active, startPlayback, stopLoop, updateMasterVolume, volume]);
useEffect(
() =>
subscribeHostAppLifecycle((payload) => {
const isHostActive = payload.state === 'active' && payload.focused;
if (hostLifecycleActiveRef.current === isHostActive) {
return;
}
hostLifecycleActiveRef.current = isHostActive;
if (!isHostActive) {
suspendPlaybackForHostLifecycle();
return;
}
void startPlayback();
}),
[startPlayback, suspendPlaybackForHostLifecycle],
);
useEffect(() => () => {
stopLoop();