From 30727d353adeb4d61ee55339b6890ae672c6d197 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 22 Jun 2026 01:51:23 +0800 Subject: [PATCH] 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) --- .../hooks/usePagination-volumeKeys.test.ts | 138 ++++++++++++++++++ .../src/app/reader/hooks/usePagination.ts | 44 ++++-- 2 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/reader/hooks/usePagination-volumeKeys.test.ts diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-volumeKeys.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-volumeKeys.test.ts new file mode 100644 index 00000000..139ff838 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-volumeKeys.test.ts @@ -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 | null, + viewState: { inited: true } as Record | 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); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index f4247953..58beded3 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -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, @@ -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(() => {