fix(reader): release volume-key page-flip while TTS is playing (#4691) (#4710)

When "page turn with volume buttons" is enabled, the volume keys were
intercepted for the whole reading session, so switching from reading to
TTS left them flipping pages instead of adjusting playback volume.

Gate the volume-key interception on this book's TTS playback state
(via the existing `tts-playback-state` bus): release interception while
TTS is playing so the OS handles volume, and re-acquire it when TTS is
paused or stopped. The acquire/release pair is keyed on the playback
state so the deviceStore reference count stays balanced.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 01:51:23 +08:00
committed by GitHub
parent 316ca3c941
commit 30727d353a
2 changed files with 173 additions and 9 deletions
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act, cleanup, renderHook } from '@testing-library/react';
// Real deviceStore + eventDispatcher; only the native bridge boundary is mocked
// so we can observe the interceptKeys({ volumeKeys }) calls the store makes.
const h = vi.hoisted(() => ({
appService: { isMobileApp: true } as { isMobileApp: boolean },
viewSettings: { volumeKeysToFlip: true } as Record<string, unknown> | null,
viewState: { inited: true } as Record<string, unknown> | null,
settingsState: { settings: { hardwarePageTurner: undefined } },
}));
vi.mock('@/utils/bridge', () => ({
interceptKeys: vi.fn(),
getScreenBrightness: vi.fn(),
setScreenBrightness: vi.fn(),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: Object.assign(
() => ({
getViewSettings: () => h.viewSettings,
getViewState: () => h.viewState,
hoveredBookKey: null,
setHoveredBookKey: vi.fn(),
}),
{ getState: () => ({ hoveredBookKey: null }) },
),
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getBookData: () => ({}) }),
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: Object.assign(
(selector?: (s: typeof h.settingsState) => unknown) =>
selector ? selector(h.settingsState) : h.settingsState,
{ getState: () => h.settingsState },
),
}));
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: Object.assign(() => ({}), { getState: () => ({ sideBarBookKey: 'book-1' }) }),
}));
import { interceptKeys } from '@/utils/bridge';
import { eventDispatcher } from '@/utils/event';
import { useDeviceControlStore } from '@/store/deviceStore';
import { usePagination } from '@/app/reader/hooks/usePagination';
const BOOK_KEY = 'book-1';
const setup = () => {
const viewRef = { current: null };
const containerRef = { current: null };
return renderHook(() => usePagination(BOOK_KEY, viewRef, containerRef));
};
const emitPlayback = async (state: string, bookKey = BOOK_KEY) => {
await act(async () => {
await eventDispatcher.dispatch('tts-playback-state', { bookKey, state });
});
};
beforeEach(() => {
useDeviceControlStore.setState({
volumeKeysIntercepted: false,
volumeKeysInterceptionCount: 0,
pageTurnerKeysIntercepted: false,
pageTurnerKeysInterceptionCount: 0,
});
vi.clearAllMocks();
h.appService = { isMobileApp: true };
h.viewSettings = { volumeKeysToFlip: true };
h.viewState = { inited: true };
});
afterEach(() => {
cleanup();
});
describe('usePagination volume-key interception with TTS (#4691)', () => {
test('intercepts volume keys on mount when the setting is on and TTS is idle', () => {
setup();
expect(interceptKeys).toHaveBeenCalledWith({ volumeKeys: true });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(true);
});
test('hands volume keys back to the OS while TTS is playing', async () => {
setup();
vi.mocked(interceptKeys).mockClear();
await emitPlayback('playing');
expect(interceptKeys).toHaveBeenCalledWith({ volumeKeys: false });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(false);
});
test('restores interception when TTS is paused', async () => {
setup();
await emitPlayback('playing');
vi.mocked(interceptKeys).mockClear();
await emitPlayback('paused');
expect(interceptKeys).toHaveBeenCalledWith({ volumeKeys: true });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(true);
});
test('restores interception when TTS is stopped', async () => {
setup();
await emitPlayback('playing');
vi.mocked(interceptKeys).mockClear();
await emitPlayback('stopped');
expect(interceptKeys).toHaveBeenCalledWith({ volumeKeys: true });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(true);
});
test('ignores TTS playback state from a different book', async () => {
setup();
vi.mocked(interceptKeys).mockClear();
await emitPlayback('playing', 'other-book');
expect(interceptKeys).not.toHaveBeenCalledWith({ volumeKeys: false });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(true);
});
test('does not intercept volume keys when the setting is off', () => {
h.viewSettings = { volumeKeysToFlip: false };
setup();
expect(interceptKeys).not.toHaveBeenCalledWith({ volumeKeys: true });
expect(useDeviceControlStore.getState().volumeKeysIntercepted).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { FoliateView } from '@/types/view';
import { ViewSettings } from '@/types/book';
@@ -197,6 +197,10 @@ export const usePagination = (
// Reactive subscription: drives the effect dependency array below. The
// handlers themselves re-read via getState() to avoid stale closures.
const hardwarePageTurner = useSettingsStore((s) => s.settings.hardwarePageTurner);
// While this book's TTS is actively playing, the volume keys must control the
// system volume instead of flipping pages (#4691). A paused or stopped session
// hands them back to the page-flip interception.
const [ttsPlaying, setTtsPlaying] = useState(false);
const handlePageFlip = async (
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
@@ -407,21 +411,43 @@ export const usePagination = (
useEffect(() => {
if (!appService?.isMobileApp) return;
const viewSettings = getViewSettings(bookKey);
if (viewSettings?.volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
eventDispatcher.on('native-key-down', handlePageFlip);
return () => {
releaseVolumeKeyInterception();
eventDispatcher.off('native-key-down', handlePageFlip);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Track this book's TTS playback so volume-key interception can step aside
// while audio is playing (#4691).
useEffect(() => {
const handlePlaybackState = (event: Event) => {
const detail = (event as CustomEvent).detail as { bookKey?: string; state?: string };
if (detail?.bookKey !== bookKey) return;
setTtsPlaying(detail.state === 'playing');
};
eventDispatcher.on('tts-playback-state', handlePlaybackState);
return () => {
eventDispatcher.off('tts-playback-state', handlePlaybackState);
};
}, [bookKey]);
// Volume-key page-flip interception (mobile only). Acquired only while the
// setting is on and TTS isn't playing; the matching release on re-run/unmount
// keeps the reference count balanced.
useEffect(() => {
if (!appService?.isMobileApp) return;
const viewSettings = getViewSettings(bookKey);
if (!viewSettings?.volumeKeysToFlip || ttsPlaying) return;
acquireVolumeKeyInterception();
return () => {
releaseVolumeKeyInterception();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsPlaying]);
// Hardware page turner: native-key + DOM-key listeners and native
// media-key interception, re-evaluated whenever the setting changes.
useEffect(() => {