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(() => {