From 843ab3448bb0053638e310d3faa3cb2ba670edf5 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 6 Jul 2026 00:38:57 +0900 Subject: [PATCH] feat(tts): keep TTS playing when the book is closed (#4941) * refactor(tts): controller owns its foliate TTS instance and emits lifecycle events view.close() nulls view.tts, so the controller keeps its own handle (mirrored to view.tts while attached; reads prefer the public mirror). state becomes an accessor that dispatches tts-state-change on a microtask, and terminal conditions (end of content, error exhaustion) fire an explicit tts-session-ended: 'stopped' is a transit value that occurs on every paragraph advance and must never be read as death. Co-Authored-By: Claude Fable 5 * feat(tts): make TTSController detachable from the reader view detachView enters headless mode: layout-dependent work is guarded, the dead hook's preprocess/section-change closures are severed, and text supply continues through created documents while position events keep flowing. attachView adopts a new view without touching in-flight audio, re-seeding the fresh text instance from the old cursor AT the synchronous swap point (auto-advance during async prep would otherwise replay a paragraph) and aborting via an attach epoch when a detach supersedes it. Co-Authored-By: Claude Fable 5 * feat(tts): move media session ownership to a session-scoped bridge ttsMediaBridge binds directly to the controller (metadata per mark, clamped position state, transport handlers, the silent keep-alive element) so the lock screen keeps working when the reader hook is unmounted. The hook's last-writer-wins handler effect and its per-render re-registration are gone; the panel now derives isPlaying from the controller's state channel, so lock-screen transport keeps the in-reader UI truthful. useTTSMediaSession had no consumers left and is removed. Co-Authored-By: Claude Fable 5 * feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence Sessions key by book hash (bookKey is regenerated per open), the playback-state relay dedupes transit stopped values so paragraph advances never flicker followers, and terminal handling rides the explicit tts-session-ended event. The sleep timer survives reader unmount, and headless positions persist through the book config on disk (view/progress stores are cleared on close and reopen loads from disk). Co-Authored-By: Claude Fable 5 * feat(tts): keep TTS playing across book close and reattach on reopen Back-to-library and Android back dispatch tts-close-book (detach when the session is not terminated: transit stopped states during chapter transitions must not kill it); quit and window-destroying closes keep the hard tts-stop so the foreground service tears down with the webview. The unmount cleanup transfers ownership to the manager instead of shutting down, covering deep-link book switches and split-view pane closes. Mounting a book adopts a matching background session once the view is ready (primary pane only) and stops a different book's session unless it is still mounted elsewhere. The sleep timer moves to the manager and a one-time toast announces the first background continuation. Co-Authored-By: Claude Fable 5 * feat(tts): now-playing bar in the library for background sessions Floating pill above the shelf while a TTS session outlives its reader: cover, title, sleep-timer countdown, play/pause following the manager-relayed playback channel, and a hard stop. Tapping the body reopens the book in the SAME window regardless of the new-window preference, since the session is a per-webview singleton. Deleting the playing book stops the session before its data is cleared. The bookshelf reserves scroll clearance via a --now-playing-inset var the bar sets while visible. Co-Authored-By: Claude Fable 5 * fix(tts): make the header close button background-eligible The header X routes through handleCloseBook (onCloseBook), not handleCloseBooksToLibrary, so the sticky eligibility ref never got set and closing a book from the header hard-stopped a live TTS session. Replace the ref with an explicit keepTTSAlive parameter on saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android back, and pane closes pass true; beforeunload, quit-app, and window close invoke handleCloseBooks with an event object, which coerces to a hard stop. This also removes the stickiness where one background close would have made a later quit detach instead of stop. Verified live in Chrome dev-web: close from the header keeps audio playing with the now-playing bar shown; reopening reattaches the same session (generation numbering continues); opening a different book stops it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../components/library/NowPlayingBar.test.tsx | 157 ++++++++ .../__tests__/hooks/useTTSControl.test.tsx | 239 +++++++----- .../services/tts-controller-detach.test.ts | 264 +++++++++++++ .../services/tts-controller-lifecycle.test.ts | 205 ++++++++++ .../services/tts-media-bridge.test.ts | 167 ++++++++ .../services/tts-session-manager.test.ts | 208 ++++++++++ .../app/library/components/NowPlayingBar.tsx | 180 +++++++++ apps/readest-app/src/app/library/page.tsx | 7 + .../app/reader/components/ReaderContent.tsx | 36 +- .../src/app/reader/hooks/useTTSControl.ts | 365 +++++++++--------- .../app/reader/hooks/useTTSMediaSession.ts | 126 ------ .../src/services/tts/TTSController.ts | 189 ++++++++- .../src/services/tts/TTSSessionManager.ts | 293 ++++++++++++++ apps/readest-app/src/services/tts/index.ts | 2 + .../src/services/tts/ttsMediaBridge.ts | 307 +++++++++++++++ 15 files changed, 2324 insertions(+), 421 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx create mode 100644 apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts create mode 100644 apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts create mode 100644 apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts create mode 100644 apps/readest-app/src/__tests__/services/tts-session-manager.test.ts create mode 100644 apps/readest-app/src/app/library/components/NowPlayingBar.tsx delete mode 100644 apps/readest-app/src/app/reader/hooks/useTTSMediaSession.ts create mode 100644 apps/readest-app/src/services/tts/TTSSessionManager.ts create mode 100644 apps/readest-app/src/services/tts/ttsMediaBridge.ts diff --git a/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx b/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx new file mode 100644 index 00000000..bfb2e7f5 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; + +const pushMock = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ safeAreaInsets: { top: 0, right: 0, bottom: 20, left: 0 } }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +const navigateToReader = vi.fn(); +vi.mock('@/utils/nav', () => ({ + navigateToReader: (...args: unknown[]) => navigateToReader(...args), +})); + +const mockManager = vi.hoisted(() => { + class MockSessionManager extends EventTarget { + session: { + bookHash: string; + bookKey: string; + controller: { + state: string; + pause: ReturnType; + start: ReturnType; + }; + } | null = null; + sleepTimer: { timeoutSec: number; firesAt: number } | null = null; + stopActive = vi.fn().mockResolvedValue(undefined); + getActiveSession() { + return this.session; + } + getSleepTimer() { + return this.sleepTimer; + } + emitSessionChanged(reason: string) { + this.dispatchEvent( + new CustomEvent('session-changed', { detail: { session: this.session, reason } }), + ); + } + } + return new MockSessionManager(); +}); +vi.mock('@/services/tts', () => ({ + ttsSessionManager: mockManager, +})); + +import { eventDispatcher } from '@/utils/event'; +import NowPlayingBar from '@/app/library/components/NowPlayingBar'; + +const makeSession = (state = 'playing') => ({ + bookHash: 'hashA', + bookKey: 'hashA-r1', + controller: { + state, + pause: vi.fn().mockResolvedValue(true), + start: vi.fn().mockResolvedValue(undefined), + }, +}); + +describe('NowPlayingBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockManager.session = null; + mockManager.sleepTimer = null; + getBookData.mockReturnValue({ + book: { title: 'Alice in Wonderland', coverImageUrl: null }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + test('renders nothing without an active session', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('shows the book title while a session is active', () => { + mockManager.session = makeSession(); + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByRole('status')).toBeTruthy(); + }); + + test('hides in select mode even with an active session', () => { + mockManager.session = makeSession(); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('play/pause button drives the session controller without opening the book', () => { + const session = makeSession('playing'); + mockManager.session = session; + render(); + fireEvent.click(screen.getByLabelText('Pause')); + expect(session.controller.pause).toHaveBeenCalled(); + expect(navigateToReader).not.toHaveBeenCalled(); + }); + + test('glyph follows the relayed tts-playback-state channel', async () => { + mockManager.session = makeSession('playing'); + render(); + expect(screen.getByLabelText('Pause')).toBeTruthy(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: 'hashA-r1', + state: 'paused', + }); + }); + expect(screen.getByLabelText('Play')).toBeTruthy(); + }); + + test('stop hides the bar optimistically and stops the session', () => { + mockManager.session = makeSession(); + const { container } = render(); + fireEvent.click(screen.getByLabelText('Stop reading aloud')); + expect(mockManager.stopActive).toHaveBeenCalledWith('user'); + expect(container.firstChild).toBeNull(); + }); + + test('tapping the body opens the book in the SAME window', () => { + mockManager.session = makeSession(); + render(); + fireEvent.click(screen.getByText('Alice in Wonderland')); + expect(navigateToReader).toHaveBeenCalledWith(expect.anything(), ['hashA']); + }); + + test('disappears when the manager reports the session stopped', () => { + mockManager.session = makeSession(); + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + act(() => { + mockManager.session = null; + mockManager.emitSessionChanged('stopped'); + }); + expect(container.firstChild).toBeNull(); + }); + + test('shows a sleep timer countdown chip when a timer is armed', () => { + mockManager.session = makeSession(); + mockManager.sleepTimer = { timeoutSec: 600, firesAt: Date.now() + 90_000 }; + render(); + expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index 5b4bc972..b3cb9b09 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -71,6 +71,7 @@ const mockBookData = { vi.mock('@/store/readerStore', () => { const store = { hoveredBookKey: null, + bookKeys: ['book-1'], getView: () => mockView, getProgress: () => mockProgress, getViewSettings: () => mockViewSettings, @@ -151,6 +152,11 @@ vi.mock('@/services/tts', () => ({ ensureTimeline: vi.fn().mockResolvedValue(null), getPlaybackInfo: vi.fn().mockReturnValue(null), seekToTime: vi.fn().mockResolvedValue(undefined), + detachView: vi.fn(), + attachView: vi.fn().mockResolvedValue(undefined), + getSpeakingLang: vi.fn().mockReturnValue('en'), + terminated: false, + isViewAttached: true, state: 'idle', addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -163,10 +169,28 @@ vi.mock('@/services/tts', () => ({ vi.mock('@/libs/mediaSession', () => ({ TauriMediaSession: class {}, + getMediaSession: vi.fn(() => null), })); -const { mockMediaSessionRef } = vi.hoisted(() => ({ - mockMediaSessionRef: { current: null as unknown }, +const { mockSessionManager } = vi.hoisted(() => ({ + mockSessionManager: { + claim: vi.fn(), + detach: vi.fn(), + release: vi.fn(), + adopt: vi.fn(), + getSessionByHash: vi.fn((_hash: string) => null as unknown), + getActiveSession: vi.fn(() => null as unknown), + stopActive: vi.fn().mockResolvedValue(undefined), + setSleepTimer: vi.fn(), + getSleepTimer: vi.fn(() => null), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, +})); + +vi.mock('@/services/tts/TTSSessionManager', () => ({ + getBookHashFromKey: (key: string) => key.split('-')[0]!, + ttsSessionManager: mockSessionManager, })); vi.mock('@/utils/ssml', () => ({ @@ -184,6 +208,7 @@ vi.mock('@/utils/cfi', () => ({ vi.mock('@/utils/misc', () => ({ getLocale: () => 'en', + stubTranslation: (key: string) => key, })); vi.mock('@/utils/ttsMetadata', () => ({ @@ -207,22 +232,9 @@ vi.mock('@/utils/ttsTime', () => ({ }), })); -const { mockDeinitMediaSession } = vi.hoisted(() => ({ - mockDeinitMediaSession: vi.fn(() => Promise.resolve()), -})); - -vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({ - useTTSMediaSession: () => ({ - mediaSessionRef: mockMediaSessionRef, - unblockAudio: vi.fn(), - releaseUnblockAudio: vi.fn(), - initMediaSession: vi.fn().mockResolvedValue(undefined), - deinitMediaSession: mockDeinitMediaSession, - }), -})); - // Imports must come AFTER vi.mock calls so they pick up the mocked modules. import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; +import { ttsMediaBridge } from '@/services/tts/ttsMediaBridge'; import { eventDispatcher } from '@/utils/event'; import { useReaderStore } from '@/store/readerStore'; @@ -349,8 +361,6 @@ describe('useTTSControl handleStop resilience (#4676)', () => { beforeEach(() => { ttsControllerInstances.length = 0; pendingInitResolvers.length = 0; - mockDeinitMediaSession.mockReset(); - mockDeinitMediaSession.mockResolvedValue(undefined); }); afterEach(() => { @@ -407,9 +417,11 @@ describe('useTTSControl handleStop resilience (#4676)', () => { it('tears down the media session even when controller.shutdown never resolves', async () => { // Regression for the lock-screen Now Playing lingering with iOS system TTS: // the media-session teardown must not be gated behind the controller's own - // shutdown, which can stall. + // shutdown, which can stall. The media session is owned by ttsMediaBridge + // now; the teardown is its unbind(). + const unbindSpy = vi.spyOn(ttsMediaBridge, 'unbind'); const controller = await startSession(); - mockDeinitMediaSession.mockClear(); + unbindSpy.mockClear(); controller.shutdown.mockReturnValueOnce(new Promise(() => {})); await act(async () => { @@ -417,7 +429,8 @@ describe('useTTSControl handleStop resilience (#4676)', () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }); - expect(mockDeinitMediaSession).toHaveBeenCalled(); + expect(unbindSpy).toHaveBeenCalled(); + unbindSpy.mockRestore(); }); }); @@ -488,47 +501,32 @@ describe('useTTSControl handleHighlightMark cross-section navigation', () => { }); }); -describe('useTTSControl media-session position and seek', () => { - interface FakeWebMediaSession { - setActionHandler: ReturnType; - setPositionState: ReturnType; - handlers: Map void>; - metadata: unknown; - playbackState: string; - } - - const makeFakeMediaSession = (): FakeWebMediaSession => { - const handlers = new Map void>(); - return { - handlers, - metadata: null, - playbackState: 'none', - setActionHandler: vi.fn((action: string, cb: (d: MediaSessionActionDetails) => void) => { - handlers.set(action, cb); - }), - setPositionState: vi.fn(), - }; - }; - +describe('useTTSControl background session lifecycle', () => { type ControllerMock = { - ensureTimeline: ReturnType; - getPlaybackInfo: ReturnType; - seekToTime: ReturnType; - addEventListener: ReturnType; + shutdown: ReturnType; + detachView: ReturnType; + attachView: ReturnType; + terminated: boolean; + state: string; }; beforeEach(() => { ttsControllerInstances.length = 0; pendingInitResolvers.length = 0; - mockMediaSessionRef.current = makeFakeMediaSession(); + mockSessionManager.claim.mockClear(); + mockSessionManager.detach.mockClear(); + mockSessionManager.release.mockClear(); + mockSessionManager.adopt.mockClear(); + mockSessionManager.stopActive.mockClear(); + mockSessionManager.getSessionByHash.mockReturnValue(null); + mockSessionManager.getActiveSession.mockReturnValue(null); }); afterEach(() => { - mockMediaSessionRef.current = null; cleanup(); }); - const startTTS = async () => { + const startSession = async () => { render(); await act(async () => { const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); @@ -539,51 +537,116 @@ describe('useTTSControl media-session position and seek', () => { return ttsControllerInstances[0] as ControllerMock; }; - it('registers a seekto handler that seeks the controller in seconds', async () => { - const controller = await startTTS(); - const fake = mockMediaSessionRef.current as FakeWebMediaSession; - expect(fake.handlers.has('seekto')).toBe(true); - await act(async () => { - fake.handlers.get('seekto')!({ seekTime: 42 } as MediaSessionActionDetails); - for (let i = 0; i < 5; i++) await Promise.resolve(); - }); - expect(controller.seekToTime).toHaveBeenCalledWith(42); + it('claims the session at controller birth', async () => { + await startSession(); + expect(mockSessionManager.claim).toHaveBeenCalledWith( + 'book-1', + ttsControllerInstances[0], + expect.objectContaining({ bookKey: 'book-1', title: 'T' }), + ); }); - it('pushes clamped position state on tts-speak-mark', async () => { - const controller = await startTTS(); - controller.getPlaybackInfo.mockReturnValue({ - position: 20, - duration: 10, - measuredFraction: 1, - }); - const markListener = controller.addEventListener.mock.calls.find( - ([type]) => type === 'tts-speak-mark', - )?.[1] as (e: Event) => void; - expect(markListener).toBeDefined(); - await act(async () => { - markListener(new CustomEvent('tts-speak-mark', { detail: { text: 'hi', name: '0' } })); - for (let i = 0; i < 5; i++) await Promise.resolve(); - }); - const fake = mockMediaSessionRef.current as FakeWebMediaSession; - expect(fake.setPositionState).toHaveBeenCalledWith({ - duration: 10, - position: 10, // clamped to duration, never skipped - playbackRate: 1, + it('unmount while the session lives transfers ownership (detach, no shutdown)', async () => { + const controller = await startSession(); + controller.state = 'playing'; + controller.terminated = false; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-1', + controller, }); + cleanup(); // unmounts the hook + expect(mockSessionManager.detach).toHaveBeenCalledWith('book'); + expect(controller.shutdown).not.toHaveBeenCalled(); }); - it('does not push position state while the timeline is unavailable', async () => { - const controller = await startTTS(); - controller.getPlaybackInfo.mockReturnValue(null); - const markListener = controller.addEventListener.mock.calls.find( - ([type]) => type === 'tts-speak-mark', - )?.[1] as (e: Event) => void; + it('unmount after termination shuts down and releases', async () => { + const controller = await startSession(); + controller.terminated = true; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-1', + controller, + }); + cleanup(); + expect(controller.shutdown).toHaveBeenCalled(); + expect(mockSessionManager.release).toHaveBeenCalledWith('book'); + }); + + it('tts-close-book detaches a live session; tts-stop stays a hard stop', async () => { + const controller = await startSession(); + controller.terminated = false; await act(async () => { - markListener(new CustomEvent('tts-speak-mark', { detail: { text: 'hi', name: '0' } })); + await eventDispatcher.dispatch('tts-close-book', { bookKey: 'book-1' }); + }); + expect(mockSessionManager.detach).toHaveBeenCalledWith('book'); + expect(controller.shutdown).not.toHaveBeenCalled(); + + await act(async () => { + await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); for (let i = 0; i < 5; i++) await Promise.resolve(); }); - const fake = mockMediaSessionRef.current as FakeWebMediaSession; - expect(fake.setPositionState).not.toHaveBeenCalled(); + expect(controller.shutdown).toHaveBeenCalled(); + expect(mockSessionManager.release).toHaveBeenCalledWith('book'); + }); + + it('mounting a book stops an active session of a different, unmounted book', async () => { + mockSessionManager.getActiveSession.mockReturnValue({ + bookHash: 'otherhash', + bookKey: 'otherhash-r9', + controller: {}, + }); + render(); + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + expect(mockSessionManager.stopActive).toHaveBeenCalledWith('replaced'); + }); + + it('adopts a live session for the same book without constructing a controller', async () => { + const liveController = { + state: 'playing', + terminated: false, + isViewAttached: false, + shutdown: vi.fn(), + detachView: vi.fn(), + attachView: vi.fn().mockResolvedValue(undefined), + getSpeakingLang: vi.fn().mockReturnValue('en'), + getCurrentHighlightCfi: vi.fn().mockReturnValue(null), + getSpokenSentence: vi.fn().mockReturnValue(null), + updateHighlightOptions: vi.fn(), + setHighlightGranularity: vi.fn(), + getVoiceId: vi.fn().mockReturnValue(''), + setTargetLang: vi.fn(), + setLang: vi.fn(), + setRate: vi.fn(), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forward: vi.fn().mockResolvedValue(undefined), + backward: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + redispatchPosition: vi.fn(), + }; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-old', + controller: liveController, + }); + render(); + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); + expect(ttsControllerInstances).toHaveLength(0); // no new controller + expect(mockSessionManager.adopt).toHaveBeenCalledWith( + 'book-1', + expect.objectContaining({ bookKey: 'book-1' }), + ); + expect(liveController.attachView).toHaveBeenCalledWith( + mockView, + expect.objectContaining({ bookKey: 'book-1' }), + ); }); }); diff --git a/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts b/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts new file mode 100644 index 00000000..3bb52c3f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TTSController } from '@/services/tts/TTSController'; +import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient'; +import { FoliateView } from '@/types/view'; + +// Detach/attach suite: headless operation guards, swap-time re-seed, and +// attach-epoch cancellation. + +const makeMockClient = (name: string): TTSClient => ({ + name, + initialized: true, + init: vi.fn().mockResolvedValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + speak: vi.fn().mockImplementation(async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + setPrimaryLang: vi.fn(), + setRate: vi.fn().mockResolvedValue(undefined), + setPitch: vi.fn().mockResolvedValue(undefined), + setVoice: vi.fn().mockResolvedValue(undefined), + getAllVoices: vi.fn().mockResolvedValue([]), + getVoices: vi.fn().mockResolvedValue([]), + getGranularities: vi.fn().mockReturnValue(['sentence']), + supportsWordBoundaries: vi.fn().mockReturnValue(false), + getVoiceId: vi.fn().mockReturnValue('detach-voice'), + getSpeakingLang: vi.fn().mockReturnValue('en'), +}); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('web-speech')); + }), +})); +vi.mock('@/services/tts/EdgeTTSClient', () => ({ + EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('edge-tts')); + }), +})); +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('native')); + }), +})); +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredClient: vi.fn().mockReturnValue('edge-tts'), + setPreferredClient: vi.fn(), + setPreferredVoice: vi.fn(), + getPreferredVoice: vi.fn().mockReturnValue(null), + }, +})); +vi.mock('foliate-js/overlayer.js', () => ({ Overlayer: { highlight: 'highlightFn' } })); + +interface MockTtsInstance { + start: ReturnType; + resume: ReturnType; + next: ReturnType; + prev: ReturnType; + from: ReturnType; + setMark: ReturnType; + getLastRange: ReturnType; + doc: Document | null; +} +const mockTtsInstances: MockTtsInstance[] = []; +let ttsNextReturns: (string | undefined)[] = []; + +vi.mock('foliate-js/tts.js', () => ({ + TTS: vi.fn().mockImplementation(function (this: Record, doc: Document) { + const instance = { + start: vi.fn().mockReturnValue('hello'), + resume: vi.fn().mockReturnValue('hello'), + next: vi.fn().mockImplementation(() => ttsNextReturns.shift()), + prev: vi.fn().mockReturnValue(undefined), + from: vi.fn().mockReturnValue('from'), + setMark: vi.fn().mockImplementation(() => new Range()), + getLastRange: vi.fn().mockReturnValue(undefined), + doc, + }; + Object.assign(this, instance); + mockTtsInstances.push(instance); + }), + getSentences: vi.fn().mockImplementation(function* () {}), +})); +vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() })); +vi.mock('@/utils/ssml', () => ({ + filterSSMLWithLang: vi.fn((ssml: string) => ssml), + parseSSMLMarks: vi.fn(() => ({ + plainText: 'hello', + marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }], + })), +})); +vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) })); +vi.mock('@/utils/lang', () => ({ + isValidLang: vi.fn(() => true), + isCJKLang: vi.fn(() => false), +})); + +interface ViewOptions { + createDocument?: () => Promise; + rendered?: boolean; +} + +const makeView = (options: ViewOptions = {}): { view: FoliateView; overlayer: unknown } => { + const doc = document.implementation.createHTMLDocument('section'); + const overlayer = { add: vi.fn(), remove: vi.fn() }; + const view = { + book: { + sections: [ + { createDocument: options.createDocument ?? vi.fn().mockResolvedValue(doc) }, + { createDocument: vi.fn().mockResolvedValue(doc) }, + ], + }, + renderer: { + getContents: () => (options.rendered === false ? [] : [{ doc, index: 0, overlayer }]), + primaryIndex: 0, + }, + language: { isCJK: false }, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'), + resolveCFI: vi.fn().mockImplementation(() => ({ index: 0, anchor: () => new Range() })), + tts: null, + } as unknown as FoliateView; + return { view, overlayer }; +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('TTSController detach/attach', () => { + let controller: TTSController; + let view: FoliateView; + let onSectionChange: ReturnType Promise>>; + + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockTtsInstances.length = 0; + ttsNextReturns = []; + onSectionChange = vi.fn<(sectionIndex: number) => Promise>().mockResolvedValue(undefined); + ({ view } = makeView()); + controller = new TTSController(null, view, false, undefined, onSectionChange); + await controller.init(); + await controller.initViewTTS(0); + }); + + test('starts attached; detachView flips the flag idempotently', () => { + expect(controller.isViewAttached).toBe(true); + controller.detachView(); + expect(controller.isViewAttached).toBe(false); + expect(() => controller.detachView()).not.toThrow(); + }); + + test('detached playback advances without touching layout or dead callbacks', async () => { + const preprocess = vi.fn(async (ssml: string) => ssml); + controller.preprocessCallback = preprocess; + const positions: string[] = []; + controller.addEventListener('tts-position', (e) => { + positions.push((e as CustomEvent).detail.cfi); + }); + + controller.detachView(); + ttsNextReturns = ['next']; + controller.state = 'playing'; + await controller.forward(); + await flush(); + + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + // Dead-hook closures are severed at detach. + expect(preprocess).not.toHaveBeenCalled(); + expect(onSectionChange).not.toHaveBeenCalled(); + // Position events keep flowing for persistence/lock-screen. + expect(positions.length).toBeGreaterThan(0); + }); + + test('attachView swaps to a new view: re-seeds, rebinds, invalidates timeline', async () => { + controller.detachView(); + + // The old instance's cursor is the seed source. + const oldRange = new Range(); + mockTtsInstances[0]!.getLastRange.mockReturnValue(oldRange); + + const { view: newView } = makeView(); + const anchored = new Range(); + (newView.resolveCFI as ReturnType).mockReturnValue({ + index: 0, + anchor: () => anchored, + }); + const newPreprocess = vi.fn(async (ssml: string) => ssml); + const newSectionChange = vi.fn().mockResolvedValue(undefined); + + await controller.attachView(newView, { + bookKey: 'hash-new123', + preprocessCallback: newPreprocess, + onSectionChange: newSectionChange, + }); + + expect(controller.isViewAttached).toBe(true); + expect(controller.view).toBe(newView); + expect(mockTtsInstances).toHaveLength(2); + const newTts = mockTtsInstances[1]!; + // Seeded at the old cursor, anchored into the NEW doc, SSML discarded. + expect(newTts.from).toHaveBeenCalledWith(anchored); + expect(controller.ttsClient.speak).not.toHaveBeenCalled(); + // The mirror points at the new instance. + expect(newView.tts).toBeTruthy(); + expect(controller.preprocessCallback).toBe(newPreprocess); + expect(controller.onSectionChange).toBe(newSectionChange); + }); + + test('attach re-seeds from the cursor position at swap time, not prep time', async () => { + controller.detachView(); + + const earlyRange = new Range(); + const lateRange = new Range(); + mockTtsInstances[0]!.getLastRange.mockReturnValue(earlyRange); + + // Gate the new view's section doc so prep stays in flight. + let releaseDoc!: (doc: Document) => void; + const gated = new Promise((resolve) => { + releaseDoc = resolve; + }); + const { view: newView } = makeView({ createDocument: () => gated, rendered: false }); + const anchorSpy = vi.fn().mockReturnValue(new Range()); + (newView.resolveCFI as ReturnType).mockReturnValue({ + index: 0, + anchor: anchorSpy, + }); + const getCFISpy = newView.getCFI as ReturnType; + + const attaching = controller.attachView(newView, { bookKey: 'hash-race' }); + await flush(); + // The old cursor advances while prep is in flight (paragraph auto-advance). + mockTtsInstances[0]!.getLastRange.mockReturnValue(lateRange); + releaseDoc(document.implementation.createHTMLDocument('late')); + await attaching; + + // The CFI for the seed must be computed from the LATE range. + const seedCall = getCFISpy.mock.calls.find(([, range]) => range === lateRange); + expect(seedCall).toBeTruthy(); + expect(getCFISpy.mock.calls.some(([, range]) => range === earlyRange)).toBe(false); + }); + + test('detachView during attach prep cancels the attach', async () => { + controller.detachView(); + + let releaseDoc!: (doc: Document) => void; + const gated = new Promise((resolve) => { + releaseDoc = resolve; + }); + const { view: newView } = makeView({ createDocument: () => gated, rendered: false }); + + const attaching = controller.attachView(newView, { bookKey: 'hash-cancel' }); + await flush(); + controller.detachView(); // e.g. the new view closed while attach prepared + releaseDoc(document.implementation.createHTMLDocument('stale')); + await attaching; + + expect(controller.isViewAttached).toBe(false); + expect(controller.view).not.toBe(newView); + const staleInstance = mockTtsInstances[1]; + if (staleInstance) expect(staleInstance.from).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts b/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts new file mode 100644 index 00000000..305499c6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TTSController } from '@/services/tts/TTSController'; +import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient'; +import { FoliateView } from '@/types/view'; + +// Focused lifecycle suite: controller-owned #tts, the state-change event +// channel, and terminal (tts-session-ended) semantics. Mocks mirror the +// controller-timeline suite's harness. + +const makeMockClient = (name: string): TTSClient => ({ + name, + initialized: true, + init: vi.fn().mockResolvedValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + speak: vi.fn().mockImplementation(async function* (): AsyncIterable { + yield { code: 'end', message: 'done' }; + }), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + setPrimaryLang: vi.fn(), + setRate: vi.fn().mockResolvedValue(undefined), + setPitch: vi.fn().mockResolvedValue(undefined), + setVoice: vi.fn().mockResolvedValue(undefined), + getAllVoices: vi.fn().mockResolvedValue([]), + getVoices: vi.fn().mockResolvedValue([]), + getGranularities: vi.fn().mockReturnValue(['sentence']), + supportsWordBoundaries: vi.fn().mockReturnValue(false), + getVoiceId: vi.fn().mockReturnValue('lifecycle-voice'), + getSpeakingLang: vi.fn().mockReturnValue('en'), +}); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('web-speech')); + }), +})); +vi.mock('@/services/tts/EdgeTTSClient', () => ({ + EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('edge-tts')); + }), +})); +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('native')); + }), +})); +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredClient: vi.fn().mockReturnValue('edge-tts'), + setPreferredClient: vi.fn(), + setPreferredVoice: vi.fn(), + getPreferredVoice: vi.fn().mockReturnValue(null), + }, +})); +vi.mock('foliate-js/overlayer.js', () => ({ Overlayer: { highlight: 'highlightFn' } })); + +// Mutable per-test behavior for the mocked foliate TTS instance. +let ttsNextReturns: (string | undefined)[] = []; +interface MockTtsInstance { + start: ReturnType; + resume: ReturnType; + next: ReturnType; + prev: ReturnType; + from: ReturnType; + setMark: ReturnType; + getLastRange: ReturnType; + doc: Document | null; +} +const mockTtsInstances: MockTtsInstance[] = []; + +vi.mock('foliate-js/tts.js', () => ({ + TTS: vi.fn().mockImplementation(function (this: Record) { + const instance = { + start: vi.fn().mockReturnValue('hello'), + resume: vi.fn().mockReturnValue('hello'), + next: vi.fn().mockImplementation(() => ttsNextReturns.shift()), + prev: vi.fn().mockReturnValue(undefined), + from: vi.fn().mockReturnValue('from'), + setMark: vi.fn().mockReturnValue(undefined), + getLastRange: vi.fn().mockReturnValue(undefined), + doc: null, + }; + Object.assign(this, instance); + mockTtsInstances.push(instance); + }), + getSentences: vi.fn().mockImplementation(function* () {}), +})); +vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() })); +vi.mock('@/utils/ssml', () => ({ + filterSSMLWithLang: vi.fn((ssml: string) => ssml), + parseSSMLMarks: vi.fn(() => ({ + plainText: 'hello', + marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }], + })), +})); +vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) })); +vi.mock('@/utils/lang', () => ({ + isValidLang: vi.fn(() => true), + isCJKLang: vi.fn(() => false), +})); + +const makeView = (): FoliateView => { + const doc = document.implementation.createHTMLDocument('t'); + return { + book: { sections: [{ createDocument: vi.fn().mockResolvedValue(doc) }] }, + renderer: { + getContents: () => [{ doc, index: 0, overlayer: { add: vi.fn(), remove: vi.fn() } }], + primaryIndex: 0, + }, + language: { isCJK: false }, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'), + resolveCFI: vi.fn().mockReturnValue({ anchor: () => new Range() }), + tts: null, + } as unknown as FoliateView; +}; + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('TTSController lifecycle', () => { + let controller: TTSController; + let view: FoliateView; + + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + ttsNextReturns = []; + mockTtsInstances.length = 0; + view = makeView(); + controller = new TTSController(null, view); + await controller.init(); + await controller.initViewTTS(0); + }); + + test('survives view.tts being nulled by view.close()', async () => { + ttsNextReturns = ['next']; + view.tts = null; // what foliate view.close() does + controller.state = 'playing'; + await controller.forward(); + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + }); + + test('state changes dispatch tts-state-change once per actual change, on a microtask', async () => { + const events: string[] = []; + controller.addEventListener('tts-state-change', (e) => { + events.push((e as CustomEvent).detail.state); + }); + controller.state = 'playing'; + expect(events).toHaveLength(0); // deferred, not re-entrant + await flushMicrotasks(); + expect(events).toEqual(['playing']); + controller.state = 'playing'; // idempotent assignment + await flushMicrotasks(); + expect(events).toEqual(['playing']); + controller.state = 'paused'; + await flushMicrotasks(); + expect(events).toEqual(['playing', 'paused']); + }); + + test('a paragraph-advance cycle fires no tts-session-ended', async () => { + const ended = vi.fn(); + controller.addEventListener('tts-session-ended', ended); + // Hold the auto-advance chain after one paragraph: a speak that ends on + // 'boundary' does not trigger forward(), so the single advance under test + // is isolated from the mock queue running dry. + (controller.ttsClient.speak as ReturnType).mockImplementation( + async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }, + ); + ttsNextReturns = ['next']; + controller.state = 'playing'; + await controller.forward(); // stop() -> 'stopped' -> speak -> 'playing' + await flushMicrotasks(); + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + expect(ended).not.toHaveBeenCalled(); + expect(controller.terminated).toBe(false); + }); + + test('end of book fires tts-session-ended exactly once with reason ended', async () => { + const ended = vi.fn(); + controller.addEventListener('tts-session-ended', ended); + ttsNextReturns = [undefined]; // no next paragraph and no next section + controller.state = 'playing'; + await controller.forward(); + await flushMicrotasks(); + expect(ended).toHaveBeenCalledTimes(1); + expect((ended.mock.calls[0]![0] as CustomEvent).detail.reason).toBe('ended'); + expect(controller.terminated).toBe(true); + }); + + test('a new speak resets terminated', async () => { + ttsNextReturns = [undefined]; + controller.state = 'playing'; + await controller.forward(); // terminates + expect(controller.terminated).toBe(true); + (controller.ttsClient.speak as ReturnType).mockImplementation( + async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }, + ); + await controller.speak('again'); + await flushMicrotasks(); + expect(controller.terminated).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts b/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts new file mode 100644 index 00000000..f02d461a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/utils/image', () => ({ + fetchImageAsBase64: vi.fn().mockResolvedValue('data:image/png;base64,x'), +})); + +import { TTSMediaBridge } from '@/services/tts/ttsMediaBridge'; +import type { TTSController } from '@/services/tts/TTSController'; + +// A controller stand-in: EventTarget + the surface the bridge consumes. +class FakeController extends EventTarget { + state = 'playing'; + terminated = false; + pause = vi.fn().mockResolvedValue(true); + start = vi.fn().mockResolvedValue(undefined); + forward = vi.fn().mockResolvedValue(undefined); + backward = vi.fn().mockResolvedValue(undefined); + seekToTime = vi.fn().mockResolvedValue(undefined); + ensureTimeline = vi.fn().mockResolvedValue(null); + getPlaybackInfo = vi.fn().mockReturnValue({ position: 12, duration: 60, measuredFraction: 1 }); + + emitMark(text: string, name: string) { + this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: { text, name } })); + } + emitState(state: string) { + this.state = state; + this.dispatchEvent(new CustomEvent('tts-state-change', { detail: { state } })); + } +} + +interface FakeWebMediaSession { + metadata: unknown; + playbackState: string; + handlers: Map void>; + setActionHandler: ReturnType; + setPositionState: ReturnType; +} + +const makeFakeMediaSession = (): FakeWebMediaSession => { + const handlers = new Map void>(); + return { + metadata: null, + playbackState: 'none', + handlers, + setActionHandler: vi.fn( + (action: string, cb: ((d: MediaSessionActionDetails) => void) | null) => { + if (cb) handlers.set(action, cb); + else handlers.delete(action); + }, + ), + setPositionState: vi.fn(), + }; +}; + +const meta = (overrides = {}) => ({ + bookKey: 'hash-abc', + title: 'Alice', + author: 'Carroll', + coverImageUrl: null, + metadataMode: 'sentence' as const, + ...overrides, +}); + +// jsdom lacks MediaMetadata; the bridge constructs it for the web path. +class FakeMediaMetadata { + title: string; + artist: string; + album: string; + constructor(init: { title: string; artist: string; album: string }) { + this.title = init.title; + this.artist = init.artist; + this.album = init.album; + } +} +vi.stubGlobal('MediaMetadata', FakeMediaMetadata); + +describe('TTSMediaBridge', () => { + let controller: FakeController; + let fake: FakeWebMediaSession; + let bridge: TTSMediaBridge; + + beforeEach(() => { + controller = new FakeController(); + fake = makeFakeMediaSession(); + bridge = new TTSMediaBridge(() => fake as unknown as MediaSession); + }); + + const bind = () => bridge.bind(controller as unknown as TTSController, meta()); + + test('bind registers transport handlers that drive the controller', async () => { + await bind(); + expect(fake.handlers.has('play')).toBe(true); + fake.handlers.get('pause')!({} as MediaSessionActionDetails); + expect(controller.pause).toHaveBeenCalled(); + controller.state = 'paused'; + fake.handlers.get('play')!({} as MediaSessionActionDetails); + expect(controller.start).toHaveBeenCalled(); + fake.handlers.get('seekto')!({ seekTime: 42 } as MediaSessionActionDetails); + expect(controller.seekToTime).toHaveBeenCalledWith(42); + fake.handlers.get('nexttrack')!({} as MediaSessionActionDetails); + expect(controller.forward).toHaveBeenCalled(); + }); + + test('speak-mark events update metadata and clamped position state headless', async () => { + await bind(); + controller.getPlaybackInfo.mockReturnValue({ position: 90, duration: 60, measuredFraction: 1 }); + controller.emitMark('Hello there, reader.', '0'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.metadata).toBeTruthy(); + expect((fake.metadata as FakeMediaMetadata).artist).toContain('Alice'); + expect(fake.setPositionState).toHaveBeenCalledWith({ + duration: 60, + position: 60, // clamped, never skipped + playbackRate: 1, + }); + }); + + test('state changes surface playing/paused but not transit stopped', async () => { + await bind(); + controller.emitState('paused'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); + controller.emitState('stopped'); // transit: paragraph advance + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); // unchanged + controller.emitState('playing'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('playing'); + }); + + test('rebinding the same controller refreshes meta without duplicate listeners', async () => { + await bind(); + await bridge.bind(controller as unknown as TTSController, meta({ bookKey: 'hash-abc-2' })); + controller.emitMark('Once more.', '1'); + await new Promise((r) => setTimeout(r, 0)); + // One metadata update per mark, not two. + expect(fake.setPositionState).toHaveBeenCalledTimes(1); + }); + + test('unbind clears handlers and stops reacting to controller events', async () => { + await bind(); + bridge.unbind(); + expect(fake.handlers.size).toBe(0); + fake.setPositionState.mockClear(); + controller.emitMark('After unbind.', '2'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.setPositionState).not.toHaveBeenCalled(); + expect(bridge.isBound).toBe(false); + }); + + test('section label falls back to the last known value when the source dies', async () => { + let label: string | undefined = 'Chapter 7'; + await bridge.bind(controller as unknown as TTSController, { + ...meta({ metadataMode: 'chapter' as const }), + getSectionLabel: () => label, + }); + controller.emitMark('First.', '0'); + await new Promise((r) => setTimeout(r, 0)); + const first = fake.metadata as FakeMediaMetadata; + label = undefined; // hook unmounted + controller.emitMark('Second.', '1'); + await new Promise((r) => setTimeout(r, 0)); + // Metadata still reflects the last known chapter, no crash, no blanking. + expect(first).toBeTruthy(); + expect(bridge.isBound).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts b/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts new file mode 100644 index 00000000..ff1feb66 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const bridgeBind = vi.fn().mockResolvedValue(undefined); +const bridgeUnbind = vi.fn(); +const releaseKeepAlive = vi.fn(); + +vi.mock('@/services/tts/ttsMediaBridge', () => ({ + ttsMediaBridge: { + bind: (...args: unknown[]) => bridgeBind(...args), + unbind: () => bridgeUnbind(), + }, + releaseUnblockAudio: () => releaseKeepAlive(), + unblockAudio: vi.fn(), +})); + +const setConfig = vi.fn(); +const saveConfig = vi.fn().mockResolvedValue(undefined); +const getConfig = vi.fn().mockReturnValue({ viewSettings: { ttsLocation: '' } }); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: { + getState: () => ({ setConfig, saveConfig, getConfig }), + }, +})); +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: { getState: () => ({ settings: { fake: true } }) }, +})); +vi.mock('@/services/environment', () => ({ default: { env: 'test' } })); +vi.mock('@/utils/bridge', () => ({ + invokeUseBackgroundAudio: vi.fn().mockResolvedValue(undefined), +})); + +import { TTSSessionManager, getBookHashFromKey } from '@/services/tts/TTSSessionManager'; +import type { TTSController } from '@/services/tts/TTSController'; +import { eventDispatcher } from '@/utils/event'; + +class FakeController extends EventTarget { + state = 'playing'; + terminated = false; + isViewAttached = true; + shutdown = vi.fn().mockResolvedValue(undefined); + detachView = vi.fn().mockImplementation(() => { + this.isViewAttached = false; + }); + + emitState(state: string) { + this.state = state; + this.dispatchEvent(new CustomEvent('tts-state-change', { detail: { state } })); + } + emitEnded(reason: string) { + this.terminated = true; + this.dispatchEvent(new CustomEvent('tts-session-ended', { detail: { reason } })); + } + emitMark(cfi: string) { + this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } })); + } +} + +const meta = (bookKey: string) => ({ + bookKey, + title: 'Alice', + author: 'Carroll', + coverImageUrl: null, + metadataMode: 'sentence' as const, +}); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('getBookHashFromKey', () => { + test('extracts the hash prefix from an ephemeral bookKey', () => { + expect(getBookHashFromKey('c9f7c5aa-1a2b3c')).toBe('c9f7c5aa'); + }); +}); + +describe('TTSSessionManager', () => { + let manager: TTSSessionManager; + let controller: FakeController; + let playbackStates: Array<{ bookKey: string; state: string }>; + let sessionEvents: Array<{ reason: string }>; + const playbackListener = (e: CustomEvent) => { + playbackStates.push(e.detail as { bookKey: string; state: string }); + }; + + const claim = (bookKey = 'hashA-r1', ctrl = controller) => + manager.claim(bookKey, ctrl as unknown as TTSController, meta(bookKey)); + + beforeEach(() => { + vi.clearAllMocks(); + manager = new TTSSessionManager(); + controller = new FakeController(); + playbackStates = []; + sessionEvents = []; + eventDispatcher.on('tts-playback-state', playbackListener as never); + manager.addEventListener('session-changed', (e) => { + sessionEvents.push({ reason: (e as CustomEvent).detail.reason }); + }); + }); + + afterEach(() => { + eventDispatcher.off('tts-playback-state', playbackListener as never); + manager.setSleepTimer(0); + }); + + test('claim registers a hash-keyed session and binds the bridge', () => { + claim(); + expect(manager.getSessionByHash('hashA')?.bookKey).toBe('hashA-r1'); + expect(manager.getActiveSession()?.bookHash).toBe('hashA'); + expect(bridgeBind).toHaveBeenCalled(); + expect(sessionEvents.at(-1)?.reason).toBe('claimed'); + }); + + test('claim for the same hash replaces the controller without stopping the slot', async () => { + claim(); + const second = new FakeController(); + claim('hashA-r2', second); + await flush(); + // Old controller unsubscribed and shut down by the manager; no bar-level stop. + expect(controller.shutdown).toHaveBeenCalled(); + expect(manager.getActiveSession()?.controller).toBe(second as unknown as TTSController); + // Old controller events no longer relay. + playbackStates.length = 0; + controller.emitState('playing'); + await flush(); + expect(playbackStates).toHaveLength(0); + }); + + test('claim for a DIFFERENT hash stops the prior session first', async () => { + claim(); + const other = new FakeController(); + claim('hashB-r1', other); + await flush(); + expect(controller.shutdown).toHaveBeenCalled(); + expect(bridgeUnbind).toHaveBeenCalled(); + expect(manager.getActiveSession()?.bookHash).toBe('hashB'); + expect(manager.getSessionByHash('hashA')).toBeNull(); + }); + + test('detach keeps the session retrievable and detaches the view', () => { + claim(); + manager.detach('hashA'); + expect(controller.detachView).toHaveBeenCalled(); + expect(manager.getSessionByHash('hashA')).not.toBeNull(); + expect(sessionEvents.at(-1)?.reason).toBe('detached'); + }); + + test('state relay: playing/paused re-emit, transit stopped is swallowed', async () => { + claim(); + controller.emitState('paused'); + controller.emitState('stopped'); // paragraph advance transit + controller.emitState('playing'); + await flush(); + const states = playbackStates.map((s) => s.state); + expect(states).toEqual(['paused', 'playing']); + }); + + test('tts-session-ended (not state) stops the session with its reason', async () => { + claim(); + manager.detach('hashA'); + controller.emitEnded('ended'); + await flush(); + expect(manager.getActiveSession()).toBeNull(); + expect(bridgeUnbind).toHaveBeenCalled(); + expect(releaseKeepAlive).toHaveBeenCalled(); + expect(playbackStates.at(-1)?.state).toBe('stopped'); + expect(sessionEvents.at(-1)?.reason).toBe('stopped'); + }); + + test('sleep timer fires stopActive and is cleared by it', async () => { + vi.useFakeTimers(); + claim(); + manager.setSleepTimer(60); + expect(manager.getSleepTimer()?.timeoutSec).toBe(60); + vi.advanceTimersByTime(61_000); + vi.useRealTimers(); + await flush(); + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getSleepTimer()).toBeNull(); + }); + + test('headless persistence writes through setConfig and flushes to disk on stop', async () => { + vi.useFakeTimers(); + claim(); + manager.detach('hashA'); + controller.emitMark('epubcfi(/6/8!/4/2)'); + expect(setConfig).toHaveBeenCalledWith( + 'hashA-r1', + expect.objectContaining({ + viewSettings: expect.objectContaining({ ttsLocation: 'epubcfi(/6/8!/4/2)' }), + }), + ); + vi.useRealTimers(); + await manager.stopActive('user'); + expect(saveConfig).toHaveBeenCalled(); + }); + + test('persistence is a no-op while the view is attached (the hook owns it)', () => { + claim(); + controller.emitMark('epubcfi(/6/8!/4/4)'); + expect(setConfig).not.toHaveBeenCalled(); + }); + + test('release clears the slot without shutting the controller down', () => { + claim(); + manager.release('hashA'); + expect(controller.shutdown).not.toHaveBeenCalled(); + expect(manager.getActiveSession()).toBeNull(); + expect(sessionEvents.at(-1)?.reason).toBe('released'); + }); +}); diff --git a/apps/readest-app/src/app/library/components/NowPlayingBar.tsx b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx new file mode 100644 index 00000000..273659d6 --- /dev/null +++ b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx @@ -0,0 +1,180 @@ +import clsx from 'clsx'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { MdClose, MdPauseCircleFilled, MdPlayCircleFilled } from 'react-icons/md'; +import { ttsSessionManager, TTSSession } from '@/services/tts'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useThemeStore } from '@/store/themeStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { eventDispatcher } from '@/utils/event'; +import { navigateToReader } from '@/utils/nav'; + +const formatCountdown = (msLeft: number) => { + const total = Math.max(0, Math.floor(msLeft / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; +}; + +interface NowPlayingBarProps { + isSelectMode: boolean; +} + +// Floating pill shown while a background TTS session is alive: the only +// in-app surface for a session whose reader is closed. Tap body reopens the +// book IN THIS WINDOW (bypassing openBookInNewWindow deliberately: the +// session is a per-webview singleton, and a new window would open a reader +// with no TTS while audio haunts this one). +const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => { + const _ = useTranslation(); + const router = useRouter(); + const { safeAreaInsets } = useThemeStore(); + const { getBookData } = useBookDataStore(); + const [session, setSession] = useState(() => + ttsSessionManager.getActiveSession(), + ); + const [isPlaying, setIsPlaying] = useState( + () => ttsSessionManager.getActiveSession()?.controller.state === 'playing', + ); + const [stopping, setStopping] = useState(false); + const [entered, setEntered] = useState(false); + const [timerLabel, setTimerLabel] = useState(''); + + useEffect(() => { + const onSessionChanged = () => { + const active = ttsSessionManager.getActiveSession(); + setSession(active); + if (!active) setStopping(false); + setIsPlaying(active ? active.controller.state === 'playing' : false); + }; + ttsSessionManager.addEventListener('session-changed', onSessionChanged); + // The glyph follows the manager-relayed channel so lock-screen transport + // keeps the bar truthful — not local optimistic taps. + const onPlaybackState = (event: CustomEvent) => { + const { state } = event.detail as { state: string }; + if (state === 'playing') setIsPlaying(true); + else if (state === 'paused') setIsPlaying(false); + }; + eventDispatcher.on('tts-playback-state', onPlaybackState); + return () => { + ttsSessionManager.removeEventListener('session-changed', onSessionChanged); + eventDispatcher.off('tts-playback-state', onPlaybackState); + }; + }, []); + + const visible = !!session && !stopping && !isSelectMode; + + // Slide-up entrance; last shelf row scrolls clear via the inset var the + // bookshelf padding consumes. + useEffect(() => { + if (!visible) { + setEntered(false); + document.body.style.removeProperty('--now-playing-inset'); + return; + } + const raf = requestAnimationFrame(() => setEntered(true)); + document.body.style.setProperty('--now-playing-inset', '64px'); + return () => { + cancelAnimationFrame(raf); + document.body.style.removeProperty('--now-playing-inset'); + }; + }, [visible]); + + useEffect(() => { + if (!visible) return; + const tick = () => { + const timer = ttsSessionManager.getSleepTimer(); + setTimerLabel(timer ? formatCountdown(timer.firesAt - Date.now()) : ''); + }; + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [visible]); + + if (!visible) return null; + + const book = getBookData(session.bookKey)?.book; + const title = book?.title ?? ''; + const coverImageUrl = book?.coverImageUrl; + + const handleToggle = () => { + const controller = session.controller; + if (controller.state === 'playing') { + void controller.pause(); + } else if (controller.state.includes('paused')) { + void controller.start(); + } + }; + + const handleStop = () => { + // Optimistic hide; the manager's stop is single-flight and the + // session-changed event confirms. + setStopping(true); + void ttsSessionManager.stopActive('user'); + }; + + const handleOpen = () => { + navigateToReader(router, [session.bookHash]); + }; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') handleOpen(); + }} + aria-label={`${_('Open Book')}: ${title}`} + className={clsx( + 'not-eink:bg-base-300 eink-bordered flex items-center gap-2 rounded-full shadow-lg', + 'h-11 max-w-[calc(100vw-1rem)] cursor-pointer ps-2 pe-1', + 'focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none', + )} + > + {coverImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + {title} + {timerLabel && ( + {timerLabel} + )} + + +
+
+ ); +}; + +export default NowPlayingBar; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 5efee874..a9224ca5 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -99,6 +99,8 @@ import ImportFromFolderDialog, { ImportFromFolderResult, } from './components/ImportFromFolderDialog'; import ImportFromUrlDialog from './components/ImportFromUrlDialog'; +import NowPlayingBar from './components/NowPlayingBar'; +import { ttsSessionManager } from '@/services/tts'; import { convertToEpubWithWorker } from '@/services/send/conversion/conversionWorker'; import { getClipOptions } from '@/services/send/clipOptions'; import { invoke } from '@tauri-apps/api/core'; @@ -1027,6 +1029,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP book.coverDownloadedAt = null; } await updateBook(envConfig, book); + if (ttsSessionManager.getSessionByHash(book.hash)) { + await ttsSessionManager.stopActive('deleted'); + } clearBookData(book.hash); if (syncBooks) pushLibrary(); } @@ -1636,6 +1641,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP style={{ paddingRight: `${insets.right}px`, paddingLeft: `${insets.left}px`, + paddingBottom: 'var(--now-playing-inset, 0px)', }} > @@ -1664,6 +1670,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP ))} + {showDetailsBook && ( = ({ i } window.addEventListener('beforeunload', handleCloseBooks); eventDispatcher.on('beforereload', handleCloseBooks); - eventDispatcher.on('close-reader', handleCloseBooks); + eventDispatcher.on('close-reader', handleCloseReaderToLibrary); eventDispatcher.on('quit-app', handleCloseBooks); return () => { window.removeEventListener('beforeunload', handleCloseBooks); eventDispatcher.off('beforereload', handleCloseBooks); - eventDispatcher.off('close-reader', handleCloseBooks); + eventDispatcher.off('close-reader', handleCloseReaderToLibrary); eventDispatcher.off('quit-app', handleCloseBooks); unlistenOnCloseWindow?.then((fn) => fn()); }; @@ -174,7 +174,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i } }; - const saveConfigAndCloseBook = async (bookKey: string) => { + const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => { console.log('Closing book', bookKey); const viewState = getViewState(bookKey); @@ -188,7 +188,13 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i } catch { console.info('Error closing book', bookKey); } - eventDispatcher.dispatch('tts-stop', { bookKey }); + // Closes that keep the webview alive (back to library, Android back, pane + // dismiss) let a live TTS session continue in the background; + // webview-destroying closes (quit, window close, reload) hard-stop so the + // media session and Android foreground service tear down with the page. + eventDispatcher.dispatch(keepTTSAlive ? 'tts-close-book' : 'tts-stop', { + bookKey, + }); await saveBookConfig(bookKey); clearViewState(bookKey); }; @@ -202,14 +208,25 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i navigateBackToLibrary(); }; - const handleCloseBooks = throttle(async () => { + const handleCloseReaderToLibrary = () => { + return handleCloseBooks(true); + }; + + // Also wired directly to beforeunload/quit-app/window-close, which pass an + // event object: only a literal `true` keeps TTS alive. + const handleCloseBooks = throttle(async (keepTTSAlive?: unknown) => { const settings = useSettingsStore.getState().settings; - await Promise.all(bookKeys.map(async (key) => await saveConfigAndCloseBook(key))); + await Promise.all( + bookKeys.map(async (key) => await saveConfigAndCloseBook(key, keepTTSAlive === true)), + ); await saveSettings(envConfig, settings); }, 200); const handleCloseBooksToLibrary = async () => { - handleCloseBooks(); + // SPA navigation in the main window (or on web) keeps the webview alive: + // TTS may continue headless. Non-main Tauri windows close their webview + // below, but their per-window TTS dies with the window either way. + handleCloseBooks(true); if (isTauriAppPlatform()) { const currentWindow = getCurrentWindow(); if (currentWindow.label === 'main') { @@ -226,7 +243,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i }; const handleCloseBook = async (bookKey: string) => { - saveConfigAndCloseBook(bookKey); + // Header X / pane close: an SPA-side close on web and the main window. + // The Tauri reader-window branches below destroy their webview, which + // takes the per-window TTS with it either way. + saveConfigAndCloseBook(bookKey, true); if (sideBarBookKey === bookKey) { setSideBarBookKey(getNextBookKey(sideBarBookKey)); } diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index d89f7685..756964b1 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -12,20 +12,18 @@ import { useTranslation } from '@/hooks/useTranslation'; import { ensureSharedAudioContext, TTSController, - TTSMark, TTSHighlightOptions, TTSVoicesGroup, } from '@/services/tts'; -import { TauriMediaSession } from '@/libs/mediaSession'; import { eventDispatcher } from '@/utils/event'; import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml'; import { throttle } from '@/utils/throttle'; import { isCfiInLocation } from '@/utils/cfi'; import { getLocale } from '@/utils/misc'; -import { buildTTSMediaMetadata } from '@/utils/ttsMetadata'; import { invokeUseBackgroundAudio } from '@/utils/bridge'; import { estimateTTSTime } from '@/utils/ttsTime'; -import { useTTSMediaSession } from './useTTSMediaSession'; +import { releaseUnblockAudio, ttsMediaBridge, unblockAudio } from '@/services/tts/ttsMediaBridge'; +import { getBookHashFromKey, ttsSessionManager } from '@/services/tts/TTSSessionManager'; interface UseTTSControlProps { bookKey: string; @@ -54,7 +52,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp const [timeoutOption, setTimeoutOption] = useState(0); const [timeoutTimestamp, setTimeoutTimestamp] = useState(0); - const [timeoutFunc, setTimeoutFunc] = useState | null>(null); const followingTTSLocationRef = useRef(true); const sectionChangingTimestampRef = useRef(0); @@ -67,14 +64,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp const [ttsController, setTtsController] = useState(null); const [ttsClientsInited, setTtsClientsInitialized] = useState(false); - const { - mediaSessionRef, - unblockAudio, - releaseUnblockAudio, - initMediaSession, - deinitMediaSession, - } = useTTSMediaSession({ bookKey }); - // Broadcast playback transitions on the app-wide bus so consumers that // can't read the hook-local isPlaying flag (RSVP, paragraph mode) can react. const emitPlaybackState = (state: 'playing' | 'paused' | 'stopped') => { @@ -169,6 +158,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp useEffect(() => { eventDispatcher.on('tts-speak', handleTTSSpeak); eventDispatcher.on('tts-stop', handleTTSStop); + eventDispatcher.on('tts-close-book', handleTTSCloseBook); eventDispatcher.on('tts-forward', handleTTSForward); eventDispatcher.on('tts-backward', handleTTSBackward); eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay); @@ -178,6 +168,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp return () => { eventDispatcher.off('tts-speak', handleTTSSpeak); eventDispatcher.off('tts-stop', handleTTSStop); + eventDispatcher.off('tts-close-book', handleTTSCloseBook); eventDispatcher.off('tts-forward', handleTTSForward); eventDispatcher.off('tts-backward', handleTTSBackward); eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay); @@ -185,20 +176,137 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence); eventDispatcher.off('tts-sync-request', handleTTSSyncRequest); if (ttsControllerRef.current) { - ttsControllerRef.current.shutdown(); + const controller = ttsControllerRef.current; + const bookHash = getBookHashFromKey(bookKey); + const session = ttsSessionManager.getSessionByHash(bookHash); + if (session?.controller === controller && !controller.terminated) { + // Ownership transfers to the manager: the session keeps playing + // headless (route unmount, deep-link book switch, split-view pane + // close all funnel through this cleanup). + ttsSessionManager.detach(bookHash); + } else { + controller.shutdown(); + ttsSessionManager.release(bookHash); + } ttsControllerRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Manager-driven stops (sleep timer, end of book, headless error, replaced + // by another book) must reconcile this reader's UI when it is mounted. + useEffect(() => { + const onSessionChanged = (e: Event) => { + const { reason } = (e as CustomEvent<{ reason: string }>).detail; + if (reason !== 'stopped' || !ttsControllerRef.current) return; + ttsControllerRef.current = null; + setTtsController(null); + setIsPlaying(false); + setIsPaused(false); + setShowIndicator(false); + setShowBackToCurrentTTSLocation(false); + setTTSEnabled(bookKey, false); + setTimeoutOption(0); + setTimeoutTimestamp(0); + onRequestHidePanel?.(); + }; + ttsSessionManager.addEventListener('session-changed', onSessionChanged); + return () => ttsSessionManager.removeEventListener('session-changed', onSessionChanged); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + + // Opening a book whose hash doesn't match the active session stops it — + // unless that session's book is still mounted elsewhere (split view). + useEffect(() => { + const active = ttsSessionManager.getActiveSession(); + if (!active) return; + const mountedHashes = useReaderStore.getState().bookKeys.map(getBookHashFromKey); + if (!mountedHashes.includes(active.bookHash)) { + void ttsSessionManager.stopActive('replaced'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + + // Seamless reattach: adopt a live background session for this book (same + // hash, fresh bookKey) once its view is ready. Audio never stops; the view + // catches up. Adoption runs only in the primary pane for the hash. + useEffect(() => { + const bookHash = getBookHashFromKey(bookKey); + const session = ttsSessionManager.getSessionByHash(bookHash); + if (!session || session.controller.terminated) return; + if (ttsControllerRef.current === session.controller) return; + const primaryKey = useReaderStore + .getState() + .bookKeys.find((k) => getBookHashFromKey(k) === bookHash); + if (primaryKey !== bookKey) return; + + let cancelled = false; + const tryAdopt = async (): Promise => { + if (cancelled || isStartingTTSRef.current) return false; + const view = getView(bookKey); + if (!view) return false; + isStartingTTSRef.current = true; + try { + const controller = session.controller; + ttsControllerRef.current = controller; + setTtsController(controller); + // Indicator on at adoption START so it never flickers in after the + // async attach resolves. + setShowIndicator(true); + setTtsClientsInitialized(true); + setTTSEnabled(bookKey, true); + const paused = controller.state.includes('paused'); + setIsPlaying(!paused); + setIsPaused(paused); + emitPlaybackState(paused ? 'paused' : 'playing'); + const timer = ttsSessionManager.getSleepTimer(); + setTimeoutOption(timer?.timeoutSec ?? 0); + setTimeoutTimestamp(timer?.firesAt ?? 0); + const bookData = getBookData(bookKey); + if (bookData?.book) { + ttsSessionManager.adopt(bookKey, { + bookKey, + title: bookData.book.title, + author: bookData.book.author, + coverImageUrl: bookData.book.coverImageUrl || null, + metadataMode: getViewSettings(bookKey)?.ttsMediaMetadata ?? 'sentence', + getSectionLabel: () => getProgress(bookKey)?.sectionLabel, + }); + } + await controller.attachView(view, { + bookKey, + preprocessCallback: preprocessSSMLForTTS, + onSectionChange: handleSectionChange, + }); + const speakingLang = controller.getSpeakingLang(); + if (speakingLang) setTtsLang(speakingLang); + } catch (err) { + console.warn('TTS session adoption failed:', err); + } finally { + isStartingTTSRef.current = false; + } + return true; + }; + + const interval = setInterval(() => { + void tryAdopt().then((done) => { + if (done) clearInterval(interval); + }); + }, 300); + void tryAdopt().then((done) => { + if (done) clearInterval(interval); + }); + return () => { + cancelled = true; + clearInterval(interval); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + // Controller event listeners (re-registered when ttsController changes) useEffect(() => { if (!ttsController || !bookKey) return; - const bookData = getBookData(bookKey); - if (!bookData || !bookData.book) return; - const { title, author, coverImageUrl } = bookData.book; - const handleNeedAuth = () => { eventDispatcher.dispatch('toast', { message: _('Please log in to use advanced TTS features'), @@ -207,49 +315,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp }); }; - const handleSpeakMark = (e: Event) => { - const progress = getProgress(bookKey); - const viewSettings = getViewSettings(bookKey); - const { sectionLabel } = progress || {}; - const mark = (e as CustomEvent).detail; - const ttsMediaMetadata = viewSettings?.ttsMediaMetadata ?? 'sentence'; - - const metadata = buildTTSMediaMetadata({ - markText: mark?.text || '', - markName: mark?.name || '', - sectionLabel: sectionLabel || '', - title, - author, - ttsMediaMetadata, - previousSectionLabel: previousSectionLabelRef.current, - }); - - if (ttsMediaMetadata === 'chapter') { - previousSectionLabelRef.current = sectionLabel; - } - - if (metadata.shouldUpdate && mediaSessionRef.current) { - const mediaSession = mediaSessionRef.current; - if (mediaSession instanceof TauriMediaSession) { - mediaSession.updateMetadata({ - title: metadata.title, - artist: metadata.artist, - album: metadata.album, - artwork: '', - }); - } else { - mediaSession.metadata = new MediaMetadata({ - title: metadata.title, - artist: metadata.artist, - album: metadata.album, - artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }], - }); - } - } - - void updateMediaSessionPosition(); - }; - const handleHighlightMark = (e: Event) => { const { cfi } = (e as CustomEvent<{ cfi: string }>).detail; const view = getView(bookKey); @@ -367,17 +432,34 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp }); }; + // Lock-screen play/pause acts on the controller through the media + // bridge; the panel derives its state from the controller, not from + // local optimistic taps. Transit 'stopped' (every paragraph advance) is + // ignored; terminal stops arrive via explicit stop paths. + const handleStateChange = (e: Event) => { + const { state } = (e as CustomEvent<{ state: string }>).detail; + if (state === 'playing') { + setIsPlaying(true); + setIsPaused(false); + playbackStateRef.current = 'playing'; + } else if (state.includes('paused')) { + setIsPlaying(false); + setIsPaused(true); + playbackStateRef.current = 'paused'; + } + }; + ttsController.addEventListener('tts-need-auth', handleNeedAuth); - ttsController.addEventListener('tts-speak-mark', handleSpeakMark); ttsController.addEventListener('tts-highlight-mark', handleHighlightMark); ttsController.addEventListener('tts-highlight-word', handleHighlightWord); ttsController.addEventListener('tts-position', handlePosition); + ttsController.addEventListener('tts-state-change', handleStateChange); return () => { ttsController.removeEventListener('tts-need-auth', handleNeedAuth); - ttsController.removeEventListener('tts-speak-mark', handleSpeakMark); ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark); ttsController.removeEventListener('tts-highlight-word', handleHighlightWord); ttsController.removeEventListener('tts-position', handlePosition); + ttsController.removeEventListener('tts-state-change', handleStateChange); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ttsController, bookKey]); @@ -581,8 +663,11 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp appService?.isIOSApp ? invokeUseBackgroundAudio({ enabled: false }).catch(() => {}) : Promise.resolve(), - deinitMediaSession().catch(() => {}), + Promise.resolve() + .then(() => ttsMediaBridge.unbind()) + .catch(() => {}), ]); + ttsSessionManager.release(getBookHashFromKey(bookKey)); }, // eslint-disable-next-line react-hooks/exhaustive-deps [appService], @@ -657,7 +742,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (appService?.isIOSApp) { await invokeUseBackgroundAudio({ enabled: true }); } - await initMediaSession(); setTtsClientsInitialized(false); setShowIndicator(true); @@ -670,6 +754,14 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp ); ttsControllerRef.current = ttsController; setTtsController(ttsController); + ttsSessionManager.claim(bookKey, ttsController, { + bookKey, + title: bookData.book.title, + author: bookData.book.author, + coverImageUrl: bookData.book.coverImageUrl || null, + metadataMode: viewSettings.ttsMediaMetadata ?? 'sentence', + getSectionLabel: () => getProgress(bookKey)?.sectionLabel, + }); await ttsController.init(); await ttsController.initViewTTS(ttsFromIndex); @@ -715,49 +807,27 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp } }; - // Push the section timeline's position/duration to the media session so the - // lock screen shows a live scrubber. Guarded against non-finite durations - // (empty/estimating timelines) and the position is CLAMPED, never skipped: - // skipping would freeze the lock-screen position when estimates overshoot. - const updateMediaSessionPosition = useCallback(async () => { - const ttsController = ttsControllerRef.current; - const mediaSession = mediaSessionRef.current; - if (!ttsController || !mediaSession) return; - await ttsController.ensureTimeline(); - const info = ttsController.getPlaybackInfo(); - if (!info || !Number.isFinite(info.duration) || info.duration <= 0) return; - const position = Math.min(Math.max(info.position, 0), info.duration); - if (mediaSession instanceof TauriMediaSession) { - await mediaSession.updatePlaybackState({ - playing: ttsControllerRef.current?.state === 'playing', - position: Math.round(position * 1000), - duration: Math.round(info.duration * 1000), - }); - } else if ('setPositionState' in mediaSession) { - try { - mediaSession.setPositionState({ - duration: info.duration, - position, - playbackRate: 1, - }); - } catch { - // Some engines reject transiently inconsistent states; the next mark - // updates again. - } + // Book close (back to library): a live session goes headless instead of + // dying. Gate on `terminated`, NOT the state value — chapter transitions + // sit in transit 'stopped' for seconds and closing during one must detach. + const handleTTSCloseBook = async (event: CustomEvent) => { + const { bookKey: closingKey } = event.detail; + if (bookKey !== closingKey) return; + const controller = ttsControllerRef.current; + if (!controller) return; + if (!controller.terminated) { + ttsSessionManager.detach(getBookHashFromKey(bookKey)); + } else { + await handleStop(bookKey); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }; // Sentence-snapped seek used by the lock-screen scrubber and the panel. - const handleSeekTo = useCallback( - async (seconds: number) => { - const ttsController = ttsControllerRef.current; - if (!ttsController) return; - await ttsController.seekToTime(seconds); - void updateMediaSessionPosition(); - }, - [updateMediaSessionPosition], - ); + const handleSeekTo = useCallback(async (seconds: number) => { + const ttsController = ttsControllerRef.current; + if (!ttsController) return; + await ttsController.seekToTime(seconds); + }, []); const handleGetPlaybackInfo = useCallback(() => { const ttsController = ttsControllerRef.current; @@ -795,16 +865,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp await ttsController.start(); } } - - if (mediaSessionRef.current) { - const mediaSession = mediaSessionRef.current; - if (mediaSession instanceof TauriMediaSession) { - await mediaSession.updatePlaybackState({ playing: !isPlaying }); - } else { - mediaSession.playbackState = isPlaying ? 'paused' : 'playing'; - } - } - }, [isPlaying, isPaused, mediaSessionRef]); + }, [isPlaying, isPaused]); const handleBackward = useCallback(async (byMark = false) => { const ttsController = ttsControllerRef.current; @@ -883,21 +944,13 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp return ''; }; - const handleSelectTimeout = (bookKey: string, value: number) => { + // The timer lives in the session manager so it survives reader unmount and + // stops a background session (a hook-local timer would fire into a dead + // closure and orphan the audio). + const handleSelectTimeout = (_bookKey: string, value: number) => { setTimeoutOption(value); - if (timeoutFunc) { - clearTimeout(timeoutFunc); - } - if (value > 0) { - setTimeoutFunc( - setTimeout(() => { - handleStop(bookKey); - }, value * 1000), - ); - setTimeoutTimestamp(Date.now() + value * 1000); - } else { - setTimeoutTimestamp(0); - } + ttsSessionManager.setSleepTimer(value); + setTimeoutTimestamp(value > 0 ? Date.now() + value * 1000 : 0); }; const handleToggleTTSBar = () => { @@ -917,60 +970,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp } }, []); - // Media session action handler effect - useEffect(() => { - const { current: mediaSession } = mediaSessionRef; - if (mediaSession) { - mediaSession.setActionHandler('play', () => { - handleTogglePlay(); - }); - - mediaSession.setActionHandler('pause', () => { - handleTogglePlay(); - }); - - mediaSession.setActionHandler('stop', () => { - handlePause(); - }); - - mediaSession.setActionHandler('seekforward', () => { - handleForward(true); - }); - - mediaSession.setActionHandler('seekbackward', () => { - handleBackward(true); - }); - - mediaSession.setActionHandler('nexttrack', () => { - handleForward(); - }); - - mediaSession.setActionHandler('previoustrack', () => { - handleBackward(); - }); - - // Seek: units differ per backend — the native plugin reports - // milliseconds, navigator.mediaSession reports seconds. Both clamp in - // TTSController.seekToTime via the timeline (past-the-end lands on the - // last sentence, never a dead gesture). - if (mediaSession instanceof TauriMediaSession) { - mediaSession.setActionHandler('seekto', ((positionMs: number) => { - handleSeekTo(positionMs / 1000); - }) as (position: number) => void); - } else { - try { - mediaSession.setActionHandler('seekto', (details: MediaSessionActionDetails) => { - if (typeof details.seekTime === 'number') { - handleSeekTo(details.seekTime); - } - }); - } catch { - // 'seekto' unsupported on this engine; the in-app scrubber covers it. - } - } - } - }, [handleTogglePlay, handlePause, handleForward, handleBackward, handleSeekTo, mediaSessionRef]); - return { isPlaying, isPaused, diff --git a/apps/readest-app/src/app/reader/hooks/useTTSMediaSession.ts b/apps/readest-app/src/app/reader/hooks/useTTSMediaSession.ts deleted file mode 100644 index 6e5a1cb0..00000000 --- a/apps/readest-app/src/app/reader/hooks/useTTSMediaSession.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useRef } from 'react'; -import { useSettingsStore } from '@/store/settingsStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { useReaderStore } from '@/store/readerStore'; -import { buildTTSMediaMetadata } from '@/utils/ttsMetadata'; -import { useTranslation } from '@/hooks/useTranslation'; -import { SILENCE_DATA } from '@/services/tts'; -import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession'; -import { fetchImageAsBase64 } from '@/utils/image'; - -interface UseTTSMediaSessionProps { - bookKey: string; -} - -export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => { - const _ = useTranslation(); - const { settings } = useSettingsStore(); - const { getBookData } = useBookDataStore(); - const { getProgress, getViewSettings } = useReaderStore(); - - const mediaSessionRef = useRef(null); - const unblockerAudioRef = useRef(null); - - // this enables WebAudio to play even when the mute toggle switch is ON - const unblockAudio = () => { - if (unblockerAudioRef.current) return; - unblockerAudioRef.current = document.createElement('audio'); - unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny'); - unblockerAudioRef.current.addEventListener('play', () => { - if ('mediaSession' in navigator) { - navigator.mediaSession.metadata = null; - } - }); - unblockerAudioRef.current.preload = 'auto'; - unblockerAudioRef.current.loop = true; - unblockerAudioRef.current.src = SILENCE_DATA; - unblockerAudioRef.current.play().catch((err) => { - // Autoplay policy rejects play() outside a user gesture (headless test - // runs, programmatic TTS starts). The keep-alive is best-effort: the - // production path calls this inside the tts-speak gesture handler, and - // a rejection here must not surface as an unhandled rejection. - console.warn('Keep-alive audio blocked:', err); - }); - }; - - const releaseUnblockAudio = () => { - if (!unblockerAudioRef.current) return; - try { - unblockerAudioRef.current.pause(); - unblockerAudioRef.current.currentTime = 0; - unblockerAudioRef.current.removeAttribute('src'); - unblockerAudioRef.current.src = ''; - unblockerAudioRef.current.load(); - unblockerAudioRef.current = null; - console.log('Unblock audio released'); - } catch (err) { - console.warn('Error releasing unblock audio:', err); - } - }; - - const initMediaSession = async () => { - const mediaSession = getMediaSession(); - if (!mediaSession) return; - - mediaSessionRef.current = mediaSession; - - if (mediaSession instanceof TauriMediaSession) { - const bookData = getBookData(bookKey); - const progress = getProgress(bookKey); - const viewSettings = getViewSettings(bookKey); - if (!bookData || !bookData.book) return; - const { title, author, coverImageUrl } = bookData.book; - const { sectionLabel } = progress || {}; - const ttsMediaMetadataMode = viewSettings?.ttsMediaMetadata ?? 'sentence'; - - let artworkImage = '/icon.png'; - try { - artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png'); - } catch { - artworkImage = await fetchImageAsBase64('/icon.png'); - } - - await mediaSession.setActive({ - active: true, - keepAppInForeground: settings.alwaysInForeground, - notificationTitle: _('Read Aloud'), - notificationText: _('Ready to read aloud'), - foregroundServiceTitle: _('Read Aloud'), - foregroundServiceText: _('Ready to read aloud'), - }); - const metadata = buildTTSMediaMetadata({ - markText: title, - markName: '0', - sectionLabel: sectionLabel || '', - title, - author, - ttsMediaMetadata: ttsMediaMetadataMode, - }); - mediaSession.updateMetadata({ - title: metadata.title, - artist: metadata.artist, - album: metadata.album, - artwork: artworkImage, - }); - } - }; - - const deinitMediaSession = async () => { - if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) { - await mediaSessionRef.current.setActive({ - active: false, - keepAppInForeground: settings.alwaysInForeground, - }); - } - mediaSessionRef.current = null; - }; - - return { - mediaSessionRef, - unblockerAudioRef, - unblockAudio, - releaseUnblockAudio, - initMediaSession, - deinitMediaSession, - }; -}; diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 8e6d1878..970aea59 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -56,6 +56,15 @@ type TTSState = const HIGHLIGHT_KEY = 'tts-highlight'; +// Hook-supplied callbacks rebound on view attach: the constructor-captured +// closures belong to whichever reader hook created the controller and die +// with it. +export interface TTSViewBindings { + bookKey: string; + preprocessCallback?: (ssml: string) => Promise; + onSectionChange?: (sectionIndex: number) => Promise; +} + // Node filter shared by the live TTS instance and the timeline enumeration — // the two MUST segment identically or timeline sentences drift from marks. const createTTSNodeFilter = () => @@ -125,7 +134,18 @@ export class TTSController extends EventTarget { // supported by every client, so 'word' falls back to it automatically. #highlightGranularity: TTSHighlightGranularity = 'word'; - state: TTSState = 'stopped'; + #state: TTSState = 'stopped'; + #terminated = false; + // View attachment: false while the session runs headless (book closed). + // The epoch invalidates in-flight attachView calls when a detach (or a + // newer attach) supersedes them. + #attached = true; + #attachEpoch = 0; + // Controller-owned foliate TTS text instance. view.close() nulls view.tts, + // so the controller keeps its own handle (mirrored to view.tts while a view + // is attached, for external consumers). + #tts: FoliateView['tts'] = null; + ttsLang: string = ''; ttsRate: number = 1.0; ttsClient: TTSClient; @@ -162,6 +182,127 @@ export class TTSController extends EventTarget { this.onSectionChange = onSectionChange; } + get state(): TTSState { + return this.#state; + } + + // The state value is a TRANSIT signal ('stopped' occurs on every paragraph + // advance and across chapter transitions) — listeners must never infer + // session death from it; that is what 'tts-session-ended' is for. Dispatch + // is deferred to a microtask so listeners never run re-entrantly inside + // stop()/error(). + set state(value: TTSState) { + if (this.#state === value) return; + this.#state = value; + queueMicrotask(() => { + this.dispatchEvent(new CustomEvent('tts-state-change', { detail: { state: value } })); + }); + } + + // True once the session reached a terminal condition (end of content or + // unrecoverable error). Rate/voice/navigation restarts never set this. + get terminated(): boolean { + return this.#terminated; + } + + // The live text instance: prefer the view's mirror (the public surface + // external callers use) and fall back to the controller-owned handle once + // view.close() nulls the mirror. + #getTts(): FoliateView['tts'] { + return this.view?.tts ?? this.#tts; + } + + #terminate(reason: 'ended' | 'error') { + if (this.#terminated) return; + this.#terminated = true; + queueMicrotask(() => { + this.dispatchEvent(new CustomEvent('tts-session-ended', { detail: { reason } })); + }); + } + + get isViewAttached(): boolean { + return this.#attached; + } + + // Enter headless mode. Audio, the abort signal, and the in-flight speak + // generator are untouched: only layout-dependent work stops. The old view + // object is retained as a pure book handle (view.close() destroys the + // renderer but keeps view.book, and getCFI/resolveCFI are book+range math). + detachView(): void { + this.#attached = false; + this.#attachEpoch++; + // The unmounted hook's closures read wiped stores; running them headless + // crashes the speak loop (e.g. proofread preprocessing on a cleared + // viewSettings). Severed here, rebound by attachView. + this.preprocessCallback = undefined; + this.onSectionChange = undefined; + } + + // Adopt a freshly mounted view without touching in-flight audio. Async prep + // builds a TTS text instance over the new view's document; the swap itself + // is synchronous and re-seeds from the OLD instance's cursor at swap time — + // forward() may have auto-advanced during prep, and a seed captured earlier + // would replay the previous paragraph. + async attachView(view: FoliateView, bindings: TTSViewBindings): Promise { + const epoch = ++this.#attachEpoch; + const oldTts = this.#getTts(); + const sectionIndex = Math.max(this.#ttsSectionIndex, 0); + + // Prep (no controller state mutated): resolve the section document from + // the new view, preferring its rendered primary content. + const contents = view.renderer.getContents(); + const primary = contents.find((x) => x.index === view.renderer.primaryIndex) ?? contents[0]; + let doc = primary && (primary.index ?? 0) === sectionIndex ? primary.doc : undefined; + if (!doc) { + const section = view.book.sections?.[sectionIndex]; + doc = section?.createDocument ? await section.createDocument() : undefined; + } + if (!doc) { + console.warn('[TTS] attachView: no document for section', sectionIndex); + return; + } + const { TTS } = await import('foliate-js/tts.js'); + const { textWalker } = await import('foliate-js/text-walker.js'); + const newTts = new TTS( + doc, + textWalker, + createTTSNodeFilter(), + this.#getHighlighter(), + this.#ttsGranularity, + ); + + // A detach (new view closed) or a newer attach superseded this one. + if (epoch !== this.#attachEpoch) return; + + // Synchronous swap. + this.view = view; + this.preprocessCallback = bindings.preprocessCallback; + this.onSectionChange = bindings.onSectionChange; + this.#attached = true; + const lastRange = oldTts?.getLastRange?.(); + if (lastRange) { + try { + // Re-derive the seed NOW: CFIs are valid from the old (content + // identical) document, and from() needs a range anchored in the new + // doc (compareBoundaryPoints throws cross-document). + const cfi = view.getCFI(sectionIndex, lastRange); + const anchored = view.resolveCFI(cfi).anchor(doc); + if (anchored) newTts.from(anchored); // position the iterator; discard SSML + } catch (err) { + console.warn('[TTS] attachView re-seed failed', err); + } + } + this.#tts = newTts; + this.view.tts = newTts; + this.#ttsDoc = doc; + // The timeline maps the old document's ranges; rebuild lazily. + this.#sectionTimeline = null; + this.#timelineSectionIndex = -1; + this.#currentSentenceIndex = -1; + this.reapplyCurrentHighlight(); + this.redispatchPosition(); + } + async init() { const availableClients = []; if (await this.ttsEdgeClient.init()) { @@ -189,6 +330,7 @@ export class TTSController extends EventTarget { } #getPrimaryContent() { + if (!this.#attached) return undefined; const contents = this.view.renderer.getContents(); const primaryIndex = this.view.renderer.primaryIndex; return (contents.find((x) => x.index === primaryIndex) ?? contents[0]) as @@ -285,7 +427,10 @@ export class TTSController extends EventTarget { this.#currentSentenceIndex = -1; this.#ttsDoc = doc; - if (this.view.tts && this.view.tts.doc === doc) { + const existing = this.#getTts(); + if (existing && existing.doc === doc) { + this.#tts = existing; + this.view.tts = existing; return true; } @@ -298,13 +443,14 @@ export class TTSController extends EventTarget { } this.#ttsGranularity = granularity; - this.view.tts = new TTS( + this.#tts = new TTS( doc, textWalker, createTTSNodeFilter(), this.#getHighlighter(), granularity, ); + this.view.tts = this.#tts; console.log(`[TTS] Initialized TTS for section ${sectionIndex}`); return true; @@ -360,7 +506,7 @@ export class TTSController extends EventTarget { if (!Number.isFinite(duration) || duration <= 0) return null; let index = this.#currentSentenceIndex; if (index < 0) { - const range = this.view.tts?.getLastRange(); + const range = this.#getTts()?.getLastRange(); index = range ? timeline.indexOfRange(range) : -1; } if (index < 0) return null; @@ -385,7 +531,7 @@ export class TTSController extends EventTarget { await this.stop(); if (!isPlaying) this.state = 'forward-paused'; this.#currentSentenceIndex = target.index; - const ssml = this.view.tts?.from(target.sentence.range); + const ssml = this.#getTts()?.from(target.sentence.range); await this.#handleNavigationWithSSML(ssml, isPlaying); if (!isPlaying) this.reapplyCurrentHighlight(); } @@ -427,11 +573,14 @@ export class TTSController extends EventTarget { async #handleNavigationWithoutSSML(initSection: () => Promise, isPlaying: boolean) { if (await initSection()) { if (isPlaying) { - this.#speak(this.view.tts?.start()); + this.#speak(this.#getTts()?.start()); } else { - this.view.tts?.start(); + this.#getTts()?.start(); } } else { + // No adjacent section in this direction: the session has run out of + // content (end of book on forward, start of book on backward). + this.#terminate('ended'); await this.stop(); } } @@ -443,7 +592,7 @@ export class TTSController extends EventTarget { } async preloadNextSSML(count: number = 4) { - const tts = this.view.tts; + const tts = this.#getTts(); if (!tts) return; // Gather all next SSMLs and rewind synchronously to avoid a race condition: @@ -494,6 +643,7 @@ export class TTSController extends EventTarget { async #speak(ssml: string | undefined | Promise, oneTime = false) { await this.stop(); + this.#terminated = false; this.#currentSpeakAbortController = new AbortController(); const { signal } = this.#currentSpeakAbortController; @@ -515,6 +665,8 @@ export class TTSController extends EventTarget { if (await this.#initTTSForNextSection()) { await this.forward(); } else { + // End of book: nothing left to speak. + this.#terminate('ended'); await this.stop(); } } @@ -573,6 +725,7 @@ export class TTSController extends EventTarget { await this.forward(); } else { this.#consecutiveSpeakErrors = 0; + this.#terminate('error'); await this.stop(); } } @@ -624,7 +777,7 @@ export class TTSController extends EventTarget { // wrong when state transiently becomes 'stopped' during forward()/backward() // — a fast play tap in that window would otherwise jump back to section start. // tts.resume() falls back to tts.next() on a fresh TTS, so it's safe at init. - const ssml = this.view.tts?.resume(); + const ssml = this.#getTts()?.resume(); if (this.state.includes('paused')) { this.resume(); } @@ -670,7 +823,7 @@ export class TTSController extends EventTarget { await this.stop(); if (!isPlaying) this.state = 'backward-paused'; - const ssml = byMark ? this.view.tts?.prevMark(!isPlaying) : this.view.tts?.prev(!isPlaying); + const ssml = byMark ? this.#getTts()?.prevMark(!isPlaying) : this.#getTts()?.prev(!isPlaying); if (!ssml) { await this.#handleNavigationWithoutSSML(() => this.#initTTSForPrevSection(), isPlaying); } else { @@ -685,7 +838,7 @@ export class TTSController extends EventTarget { await this.stop(); if (!isPlaying) this.state = 'forward-paused'; - const ssml = byMark ? this.view.tts?.nextMark(!isPlaying) : this.view.tts?.next(!isPlaying); + const ssml = byMark ? this.#getTts()?.nextMark(!isPlaying) : this.#getTts()?.next(!isPlaying); if (!ssml) { await this.#handleNavigationWithoutSSML(() => this.#initTTSForNextSection(), isPlaying); } else { @@ -763,7 +916,7 @@ export class TTSController extends EventTarget { } getSpokenSentence(): { cfi: string; text: string } | null { - const range = this.view.tts?.getLastRange(); + const range = this.#getTts()?.getLastRange(); if (!range || this.#ttsSectionIndex < 0) return null; try { const cfi = this.view.getCFI(this.#ttsSectionIndex, range); @@ -804,7 +957,7 @@ export class TTSController extends EventTarget { // suppress it. this.#suppressMarkHighlight = this.ttsClient.supportsWordBoundaries() && this.#highlightGranularity === 'word'; - const range = this.view.tts?.setMark(mark.name); + const range = this.#getTts()?.setMark(mark.name); this.#suppressMarkHighlight = false; this.#speakWordsArmed = !!range; if (this.#sectionTimeline && range) { @@ -835,11 +988,12 @@ export class TTSController extends EventTarget { // re-render). In word mode this re-draws the current word so the sentence // never reappears over it; otherwise it re-draws the sentence. reapplyCurrentHighlight() { + if (!this.#attached) return; if (this.#wordHighlightActive && this.#lastSpeakWordRange) { this.#getHighlighter()(this.#lastSpeakWordRange.cloneRange()); return; } - const range = this.view.tts?.getLastRange(); + const range = this.#getTts()?.getLastRange(); if (range) this.#getHighlighter()(range.cloneRange()); } @@ -849,6 +1003,7 @@ export class TTSController extends EventTarget { // ttsLocation, so the word position is the accurate reference. Returns null // outside word mode, where the sentence-level ttsLocation is correct. getCurrentHighlightCfi(): string | null { + if (!this.#attached) return null; if (!this.#wordHighlightActive || !this.#lastSpeakWordRange || this.#ttsSectionIndex < 0) { return null; } @@ -876,7 +1031,7 @@ export class TTSController extends EventTarget { } } catch {} } - const range = this.view.tts?.getLastRange(); + const range = this.#getTts()?.getLastRange(); if (!range) return; try { const cfi = this.view.getCFI(this.#ttsSectionIndex, range); @@ -895,7 +1050,7 @@ export class TTSController extends EventTarget { // at mark dispatch (not suppressed), so there's nothing to do here — leave // word mode off even though the client reported word boundaries. if (this.#highlightGranularity === 'sentence') return; - const range = this.view.tts?.getLastRange(); + const range = this.#getTts()?.getLastRange(); if (!range) return; this.#speakWordBaseRange = range; const matchText = rangeTextExcludingInert(range); @@ -966,6 +1121,7 @@ export class TTSController extends EventTarget { return; } console.error(e); + this.#terminate('error'); this.state = 'stopped'; } @@ -977,6 +1133,7 @@ export class TTSController extends EventTarget { this.#timelineSectionIndex = -1; this.#currentSentenceIndex = -1; this.#ttsDoc = null; + this.#tts = null; this.view.tts = null; if (this.ttsWebClient.initialized) { await this.ttsWebClient.shutdown(); diff --git a/apps/readest-app/src/services/tts/TTSSessionManager.ts b/apps/readest-app/src/services/tts/TTSSessionManager.ts new file mode 100644 index 00000000..070da9fd --- /dev/null +++ b/apps/readest-app/src/services/tts/TTSSessionManager.ts @@ -0,0 +1,293 @@ +// Single-slot, hash-keyed registry for the live TTS session. +// +// A fresh TTSController (and bookKey) is created every time a book opens — +// bookKey is `${hash}-${uniqueId()}` — so sessions key by book HASH and treat +// bookKey as ephemeral. The manager owns everything that must outlive the +// reader's React hooks: the media bridge binding, the silent keep-alive, the +// sleep timer, headless progress persistence, and the app-level playback +// state relay. It is a per-webview singleton by design (multi-window desktop +// keeps its current per-window behavior). + +import env from '@/services/environment'; +import { stubTranslation as _ } from '@/utils/misc'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { invokeUseBackgroundAudio } from '@/utils/bridge'; +import { eventDispatcher } from '@/utils/event'; +import { releaseUnblockAudio, ttsMediaBridge, TTSMediaBridgeMeta } from './ttsMediaBridge'; +import type { TTSController } from './TTSController'; + +export type TTSSessionMeta = TTSMediaBridgeMeta; + +export interface TTSSession { + bookHash: string; + bookKey: string; + controller: TTSController; +} + +export type TTSSessionStopReason = + | 'user' + | 'replaced' + | 'timeout' + | 'ended' + | 'error' + | 'deleted' + | 'quit'; + +export const getBookHashFromKey = (bookKey: string): string => bookKey.split('-')[0]!; + +// Headless position writes hit the disk at most this often; stopActive +// flushes the final position regardless. +const PERSIST_THROTTLE_MS = 10_000; + +export class TTSSessionManager extends EventTarget { + #session: TTSSession | null = null; + #meta: TTSSessionMeta | null = null; + #onStateChange: ((e: Event) => void) | null = null; + #onSessionEnded: ((e: Event) => void) | null = null; + #onHighlightMark: ((e: Event) => void) | null = null; + #lastRelayedState: 'playing' | 'paused' | null = null; + #sleepTimer: ReturnType | null = null; + #sleepTimeoutSec = 0; + #sleepFiresAt = 0; + #lastPersistAt = 0; + #pendingLocation: string | null = null; + #stopping = false; + + claim(bookKey: string, controller: TTSController, meta: TTSSessionMeta): void { + const bookHash = getBookHashFromKey(bookKey); + const existing = this.#session; + if (existing && existing.bookHash !== bookHash) { + // Starting TTS in another book replaces the single slot. + void this.stopActive('replaced'); + } else if (existing && existing.controller !== controller) { + // Same book restarted with a fresh controller: swap silently. The + // manager owns the replaced controller's teardown — and must + // unsubscribe first so the old controller's async tail can't relay. + this.#unsubscribe(existing.controller); + existing.controller.shutdown().catch(() => {}); + } + this.#session = { bookHash, bookKey, controller }; + this.#meta = meta; + this.#lastRelayedState = null; + this.#subscribe(controller); + void ttsMediaBridge.bind(controller, meta); + this.#emitSessionChanged('claimed'); + } + + getSessionByHash(bookHash: string): TTSSession | null { + return this.#session?.bookHash === bookHash ? this.#session : null; + } + + getActiveSession(): TTSSession | null { + return this.#session; + } + + // The session survives; only the view goes away. The bridge stays bound so + // the lock screen keeps working headless. + detach(bookHash: string): void { + const session = this.getSessionByHash(bookHash); + if (!session) return; + const wasPlaying = session.controller.state === 'playing' || !session.controller.terminated; + session.controller.detachView(); + this.#emitSessionChanged('detached'); + // Closing a book while it keeps talking inverts years of learned + // behavior; announce it exactly once, ever. + if (wasPlaying) { + try { + if (!localStorage.getItem('readest-tts-background-announced')) { + localStorage.setItem('readest-tts-background-announced', '1'); + eventDispatcher.dispatch('toast', { + message: _('Reading aloud continues in the background'), + type: 'info', + timeout: 3000, + }); + } + } catch { + // localStorage unavailable: skip the announcement. + } + } + } + + // Rebind bookkeeping after the reader adopts the session under a fresh + // bookKey (attachView itself is the caller's responsibility). + adopt(bookKey: string, meta: TTSSessionMeta): TTSSession | null { + const session = this.getSessionByHash(getBookHashFromKey(bookKey)); + if (!session) return null; + session.bookKey = bookKey; + this.#meta = meta; + void ttsMediaBridge.bind(session.controller, meta); + return session; + } + + async stopActive(reason: TTSSessionStopReason = 'user'): Promise { + const session = this.#session; + if (!session || this.#stopping) return; + this.#stopping = true; + const meta = this.#meta; + const wasDetached = !session.controller.isViewAttached; + this.#session = null; + this.#meta = null; + this.#clearSleepTimer(); + this.#unsubscribe(session.controller); + this.#flushLocation(session); + + // UI reconciliation first; teardown is best-effort and must not gate it + // (native shutdown can stall — see #4676). + eventDispatcher.dispatch('tts-playback-state', { bookKey: session.bookKey, state: 'stopped' }); + this.#emitSessionChanged('stopped'); + if (reason === 'replaced' || reason === 'deleted') { + eventDispatcher.dispatch('toast', { + message: `${_('Stopped reading aloud')}: ${meta?.title ?? ''}`, + type: 'info', + timeout: 3000, + }); + } else if (reason === 'error' && wasDetached) { + eventDispatcher.dispatch('toast', { + message: `${_('Read aloud stopped')}: ${meta?.title ?? ''}`, + type: 'error', + timeout: 5000, + }); + } + + ttsMediaBridge.unbind(); + releaseUnblockAudio(); + await Promise.all([ + session.controller.shutdown().catch((err) => console.warn('TTS shutdown failed:', err)), + invokeUseBackgroundAudio({ enabled: false }).catch(() => {}), + ]); + this.#stopping = false; + } + + // Clear the slot without tearing the controller down (the caller already + // ran the full stop path). + release(bookHash: string): void { + const session = this.getSessionByHash(bookHash); + if (!session) return; + this.#unsubscribe(session.controller); + this.#clearSleepTimer(); + this.#session = null; + this.#meta = null; + this.#emitSessionChanged('released'); + } + + // Sleep timer lives here so a timer armed in the reader survives unmount + // and can actually stop a background session (a hook-local timer would + // fire into a dead closure and orphan the audio). + setSleepTimer(seconds: number): void { + this.#clearSleepTimer(); + if (seconds > 0) { + this.#sleepTimeoutSec = seconds; + this.#sleepFiresAt = Date.now() + seconds * 1000; + this.#sleepTimer = setTimeout(() => { + this.#sleepTimer = null; + void this.stopActive('timeout'); + }, seconds * 1000); + } + } + + getSleepTimer(): { timeoutSec: number; firesAt: number } | null { + return this.#sleepTimer + ? { timeoutSec: this.#sleepTimeoutSec, firesAt: this.#sleepFiresAt } + : null; + } + + #clearSleepTimer(): void { + if (this.#sleepTimer) { + clearTimeout(this.#sleepTimer); + this.#sleepTimer = null; + } + this.#sleepTimeoutSec = 0; + this.#sleepFiresAt = 0; + } + + #subscribe(controller: TTSController): void { + this.#onStateChange = (e: Event) => { + const { state } = (e as CustomEvent<{ state: string }>).detail; + const session = this.#session; + if (!session || session.controller !== controller) return; + // 'stopped' is a TRANSIT value (every paragraph advance, chapter + // transitions) — relaying it would flicker every follower and the + // now-playing bar. Terminal stops arrive via tts-session-ended. + let mapped: 'playing' | 'paused' | null = null; + if (state === 'playing') mapped = 'playing'; + else if (state.includes('paused')) mapped = 'paused'; + if (!mapped || mapped === this.#lastRelayedState) return; + this.#lastRelayedState = mapped; + eventDispatcher.dispatch('tts-playback-state', { bookKey: session.bookKey, state: mapped }); + }; + this.#onSessionEnded = (e: Event) => { + const { reason } = (e as CustomEvent<{ reason: 'ended' | 'error' }>).detail; + void this.stopActive(reason); + }; + this.#onHighlightMark = (e: Event) => { + const { cfi } = (e as CustomEvent<{ cfi: string }>).detail; + this.#persistLocation(cfi); + }; + controller.addEventListener('tts-state-change', this.#onStateChange); + controller.addEventListener('tts-session-ended', this.#onSessionEnded); + controller.addEventListener('tts-highlight-mark', this.#onHighlightMark); + } + + #unsubscribe(controller: TTSController): void { + if (this.#onStateChange) { + controller.removeEventListener('tts-state-change', this.#onStateChange); + } + if (this.#onSessionEnded) { + controller.removeEventListener('tts-session-ended', this.#onSessionEnded); + } + if (this.#onHighlightMark) { + controller.removeEventListener('tts-highlight-mark', this.#onHighlightMark); + } + this.#onStateChange = null; + this.#onSessionEnded = null; + this.#onHighlightMark = null; + } + + // Headless position persistence goes through the book CONFIG on disk: + // clearViewState deletes the view/progress store entries (their setters + // no-op for a closed book) and a reopen reloads config from disk, so + // store-only writes would be lost. While a view is attached the reader + // hook persists via its own path. + #persistLocation(cfi: string): void { + const session = this.#session; + if (!session || session.controller.isViewAttached) return; + this.#pendingLocation = cfi; + const { getConfig, setConfig } = useBookDataStore.getState(); + const config = getConfig(session.bookKey); + setConfig(session.bookKey, { + viewSettings: { ...(config?.viewSettings ?? {}), ttsLocation: cfi }, + }); + const now = Date.now(); + if (now - this.#lastPersistAt >= PERSIST_THROTTLE_MS) { + this.#lastPersistAt = now; + this.#saveToDisk(session); + } + } + + #flushLocation(session: TTSSession): void { + if (this.#pendingLocation === null) return; + this.#pendingLocation = null; + this.#saveToDisk(session); + } + + #saveToDisk(session: TTSSession): void { + try { + const { getConfig, saveConfig } = useBookDataStore.getState(); + const config = getConfig(session.bookKey); + if (!config) return; + const settings = useSettingsStore.getState().settings; + void saveConfig(env, session.bookKey, config, settings); + } catch (err) { + console.warn('TTS headless persistence failed:', err); + } + } + + #emitSessionChanged(reason: 'claimed' | 'detached' | 'stopped' | 'released'): void { + this.dispatchEvent( + new CustomEvent('session-changed', { detail: { session: this.#session, reason } }), + ); + } +} + +export const ttsSessionManager = new TTSSessionManager(); diff --git a/apps/readest-app/src/services/tts/index.ts b/apps/readest-app/src/services/tts/index.ts index 16b91a45..e1e01d6d 100644 --- a/apps/readest-app/src/services/tts/index.ts +++ b/apps/readest-app/src/services/tts/index.ts @@ -6,4 +6,6 @@ export * from './NativeTTSClient'; export * from './TTSController'; export * from './TTSData'; export { ensureSharedAudioContext } from './WebAudioPlayer'; +export * from './TTSSessionManager'; +export { ttsMediaBridge, unblockAudio, releaseUnblockAudio } from './ttsMediaBridge'; export { SectionTimeline } from './SectionTimeline'; diff --git a/apps/readest-app/src/services/tts/ttsMediaBridge.ts b/apps/readest-app/src/services/tts/ttsMediaBridge.ts new file mode 100644 index 00000000..52a4405f --- /dev/null +++ b/apps/readest-app/src/services/tts/ttsMediaBridge.ts @@ -0,0 +1,307 @@ +// Session-scoped media-session ownership for TTS. +// +// The lock screen is the primary surface for background TTS: metadata, +// position state, and transport handlers must keep working after the reader +// (and its hooks) unmount. This bridge binds to a TTSController directly — +// its listeners ride controller events, not React lifecycles — and is the +// SOLE owner of media-session handlers from the moment a session starts. +// +// The silent keep-alive element lives here too: it unlocks WebAudio against +// the iOS mute switch, hosts navigator.mediaSession on platforms where a +// playing HTMLMediaElement is required (iOS lock screen, desktop Chromium +// media keys), and must survive hook unmount for a detached session. + +import { buildTTSMediaMetadata } from '@/utils/ttsMetadata'; +import { fetchImageAsBase64 } from '@/utils/image'; +import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession'; +import { SILENCE_DATA } from './TTSData'; +import type { TTSController } from './TTSController'; +import type { TTSMark, TTSMediaMetadataMode } from './types'; + +export interface TTSMediaBridgeMeta { + bookKey: string; + title: string; + author: string; + coverImageUrl: string | null; + metadataMode: TTSMediaMetadataMode; + // Live section label while the reader is mounted; returns undefined when + // the supplying hook is dead (headless) — the bridge then keeps the last + // known label rather than freezing on a stale store read. + getSectionLabel?: () => string | undefined; +} + +// --------------------------------------------------------------------------- +// Keep-alive element (module-scoped: outlives hooks by design). + +let unblockerAudio: HTMLAudioElement | null = null; + +// This enables WebAudio to play even when the mute toggle switch is ON. +export const unblockAudio = (): void => { + if (unblockerAudio) return; + unblockerAudio = document.createElement('audio'); + unblockerAudio.setAttribute('x-webkit-airplay', 'deny'); + unblockerAudio.addEventListener('play', () => { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = null; + } + }); + unblockerAudio.preload = 'auto'; + unblockerAudio.loop = true; + unblockerAudio.src = SILENCE_DATA; + // jsdom's play() returns undefined; browsers return a promise that rejects + // under autoplay policy outside a user gesture. The keep-alive is + // best-effort: the production path calls this inside the tts-speak gesture + // handler, and a rejection must not surface as an unhandled rejection. + const playing = unblockerAudio.play() as Promise | undefined; + playing?.catch((err) => { + console.warn('Keep-alive audio blocked:', err); + }); +}; + +export const releaseUnblockAudio = (): void => { + if (!unblockerAudio) return; + try { + unblockerAudio.pause(); + unblockerAudio.currentTime = 0; + unblockerAudio.removeAttribute('src'); + unblockerAudio.src = ''; + unblockerAudio.load(); + unblockerAudio = null; + console.log('Unblock audio released'); + } catch (err) { + console.warn('Error releasing unblock audio:', err); + } +}; + +// --------------------------------------------------------------------------- + +type BridgeMediaSession = TauriMediaSession | MediaSession; + +export class TTSMediaBridge { + #resolveMediaSession: () => BridgeMediaSession | null; + #mediaSession: BridgeMediaSession | null = null; + #controller: TTSController | null = null; + #meta: TTSMediaBridgeMeta | null = null; + #lastSectionLabel: string | undefined; + #previousSectionLabel: string | undefined; + #onSpeakMark: ((e: Event) => void) | null = null; + #onStateChange: ((e: Event) => void) | null = null; + + constructor(resolveMediaSession: () => BridgeMediaSession | null = getMediaSession) { + this.#resolveMediaSession = resolveMediaSession; + } + + get isBound(): boolean { + return this.#controller !== null; + } + + async bind(controller: TTSController, meta: TTSMediaBridgeMeta): Promise { + if (this.#controller === controller) { + // Re-bind on adopt: refresh the meta (new bookKey / live label source) + // without re-registering listeners or re-activating the session. + this.#meta = meta; + return; + } + this.unbind(); + this.#controller = controller; + this.#meta = meta; + this.#mediaSession = this.#resolveMediaSession(); + if (!this.#mediaSession) return; + + if (this.#mediaSession instanceof TauriMediaSession) { + let artwork = '/icon.png'; + try { + artwork = await fetchImageAsBase64(meta.coverImageUrl || '/icon.png'); + } catch { + try { + artwork = await fetchImageAsBase64('/icon.png'); + } catch { + artwork = ''; + } + } + await this.#mediaSession.setActive({ active: true }); + await this.#mediaSession.updateMetadata({ + title: meta.title, + artist: meta.author, + album: meta.title, + artwork, + }); + } + + this.#registerActionHandlers(); + + this.#onSpeakMark = (e: Event) => { + const mark = (e as CustomEvent).detail; + void this.#updateMetadata(mark); + void this.#updatePositionState(); + }; + this.#onStateChange = () => { + void this.#updatePlaybackState(); + }; + controller.addEventListener('tts-speak-mark', this.#onSpeakMark); + controller.addEventListener('tts-state-change', this.#onStateChange); + } + + unbind(): void { + if (this.#controller) { + if (this.#onSpeakMark) { + this.#controller.removeEventListener('tts-speak-mark', this.#onSpeakMark); + } + if (this.#onStateChange) { + this.#controller.removeEventListener('tts-state-change', this.#onStateChange); + } + } + const mediaSession = this.#mediaSession; + if (mediaSession) { + for (const action of [ + 'play', + 'pause', + 'stop', + 'seekforward', + 'seekbackward', + 'nexttrack', + 'previoustrack', + 'seekto', + ]) { + try { + mediaSession.setActionHandler(action as MediaSessionAction, null); + } catch { + // Unsupported actions on this engine. + } + } + if (mediaSession instanceof TauriMediaSession) { + void mediaSession.setActive({ active: false }); + } + } + this.#controller = null; + this.#meta = null; + this.#mediaSession = null; + this.#onSpeakMark = null; + this.#onStateChange = null; + this.#lastSectionLabel = undefined; + this.#previousSectionLabel = undefined; + } + + #registerActionHandlers(): void { + const mediaSession = this.#mediaSession; + if (!mediaSession) return; + const controller = () => this.#controller; + + const togglePlay = () => { + const ctrl = controller(); + if (!ctrl) return; + if (ctrl.state === 'playing') { + void ctrl.pause(); + } else if (ctrl.state.includes('paused')) { + void ctrl.start(); + } + }; + mediaSession.setActionHandler('play', togglePlay); + mediaSession.setActionHandler('pause', togglePlay); + // 'stop' keeps its long-standing pause mapping; the hard stop lives in + // the in-app surfaces (panel, now-playing bar). + mediaSession.setActionHandler('stop', () => { + const ctrl = controller(); + if (ctrl?.state === 'playing') void ctrl.pause(); + }); + mediaSession.setActionHandler('seekforward', () => void controller()?.forward(true)); + mediaSession.setActionHandler('seekbackward', () => void controller()?.backward(true)); + mediaSession.setActionHandler('nexttrack', () => void controller()?.forward()); + mediaSession.setActionHandler('previoustrack', () => void controller()?.backward()); + if (mediaSession instanceof TauriMediaSession) { + mediaSession.setActionHandler('seekto', ((positionMs: number) => { + void controller()?.seekToTime(positionMs / 1000); + }) as (position: number) => void); + } else { + try { + mediaSession.setActionHandler('seekto', (details: MediaSessionActionDetails) => { + if (typeof details.seekTime === 'number') { + void controller()?.seekToTime(details.seekTime); + } + }); + } catch { + // 'seekto' unsupported on this engine. + } + } + } + + async #updateMetadata(mark: TTSMark | undefined): Promise { + const mediaSession = this.#mediaSession; + const meta = this.#meta; + if (!mediaSession || !meta) return; + const liveLabel = meta.getSectionLabel?.(); + if (liveLabel) this.#lastSectionLabel = liveLabel; + + const metadata = buildTTSMediaMetadata({ + markText: mark?.text || '', + markName: mark?.name || '', + sectionLabel: this.#lastSectionLabel || '', + title: meta.title, + author: meta.author, + ttsMediaMetadata: meta.metadataMode, + previousSectionLabel: this.#previousSectionLabel, + }); + if (meta.metadataMode === 'chapter') { + this.#previousSectionLabel = this.#lastSectionLabel; + } + if (!metadata.shouldUpdate) return; + + if (mediaSession instanceof TauriMediaSession) { + await mediaSession.updateMetadata({ + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + artwork: '', + }); + } else { + mediaSession.metadata = new MediaMetadata({ + title: metadata.title, + artist: metadata.artist, + album: metadata.album, + artwork: [{ src: meta.coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }], + }); + } + } + + // Clamped, never skipped: skipping when the position overshoots an + // estimated duration would freeze the lock-screen scrubber. + async #updatePositionState(): Promise { + const mediaSession = this.#mediaSession; + const ctrl = this.#controller; + if (!mediaSession || !ctrl) return; + await ctrl.ensureTimeline(); + const info = ctrl.getPlaybackInfo(); + if (!info || !Number.isFinite(info.duration) || info.duration <= 0) return; + const position = Math.min(Math.max(info.position, 0), info.duration); + if (mediaSession instanceof TauriMediaSession) { + await mediaSession.updatePlaybackState({ + playing: ctrl.state === 'playing', + position: Math.round(position * 1000), + duration: Math.round(info.duration * 1000), + }); + } else if ('setPositionState' in mediaSession) { + try { + mediaSession.setPositionState({ duration: info.duration, position, playbackRate: 1 }); + } catch { + // Transiently inconsistent states reject on some engines; the next + // mark updates again. + } + } + } + + async #updatePlaybackState(): Promise { + const mediaSession = this.#mediaSession; + const ctrl = this.#controller; + if (!mediaSession || !ctrl) return; + // Transit 'stopped' flickers on every paragraph advance; only surface + // playing/paused flips to the OS. + if (ctrl.state === 'stopped' && !ctrl.terminated) return; + if (mediaSession instanceof TauriMediaSession) { + await mediaSession.updatePlaybackState({ playing: ctrl.state === 'playing' }); + } else { + mediaSession.playbackState = ctrl.state === 'playing' ? 'playing' : 'paused'; + } + } +} + +export const ttsMediaBridge = new TTSMediaBridge();