接入宿主生命周期暂停背景音乐
useBackgroundMusic 订阅 app.lifecycle 统一事件 宿主进后台或桌面失焦时暂停音频循环并 suspend WebAudio 宿主恢复 active 后按用户原音乐状态继续播放 更新测试和原生壳架构文档
This commit is contained in:
@@ -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