接入宿主生命周期暂停背景音乐
useBackgroundMusic 订阅 app.lifecycle 统一事件 宿主进后台或桌面失焦时暂停音频循环并 suspend WebAudio 宿主恢复 active 后按用户原音乐状态继续播放 更新测试和原生壳架构文档
This commit is contained in:
@@ -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 无法直接调用 HostBridge;Tauri 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 二维码。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user