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

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
+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();