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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-06 00:38:57 +09:00
committed by GitHub
parent 42f9b8fe3c
commit 843ab3448b
15 changed files with 2324 additions and 421 deletions
@@ -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<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
};
} | 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(<NowPlayingBar isSelectMode={false} />);
expect(container.firstChild).toBeNull();
});
test('shows the book title while a session is active', () => {
mockManager.session = makeSession();
render(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={true} />);
expect(container.firstChild).toBeNull();
});
test('play/pause button drives the session controller without opening the book', () => {
const session = makeSession('playing');
mockManager.session = session;
render(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={false} />);
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(<NowPlayingBar isSelectMode={false} />);
expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy();
});
});
@@ -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<void>(() => {}));
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<typeof vi.fn>;
setPositionState: ReturnType<typeof vi.fn>;
handlers: Map<string, (details: MediaSessionActionDetails) => void>;
metadata: unknown;
playbackState: string;
}
const makeFakeMediaSession = (): FakeWebMediaSession => {
const handlers = new Map<string, (details: MediaSessionActionDetails) => 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<typeof vi.fn>;
getPlaybackInfo: ReturnType<typeof vi.fn>;
seekToTime: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
detachView: ReturnType<typeof vi.fn>;
attachView: ReturnType<typeof vi.fn>;
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(<Harness />);
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(<Harness />);
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(<Harness />);
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' }),
);
});
});
@@ -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<TTSMessageEvent> {
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<string, unknown>) {
Object.assign(this, makeMockClient('web-speech'));
}),
}));
vi.mock('@/services/tts/EdgeTTSClient', () => ({
EdgeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, makeMockClient('edge-tts'));
}),
}));
vi.mock('@/services/tts/NativeTTSClient', () => ({
NativeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
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<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
next: ReturnType<typeof vi.fn>;
prev: ReturnType<typeof vi.fn>;
from: ReturnType<typeof vi.fn>;
setMark: ReturnType<typeof vi.fn>;
getLastRange: ReturnType<typeof vi.fn>;
doc: Document | null;
}
const mockTtsInstances: MockTtsInstance[] = [];
let ttsNextReturns: (string | undefined)[] = [];
vi.mock('foliate-js/tts.js', () => ({
TTS: vi.fn().mockImplementation(function (this: Record<string, unknown>, doc: Document) {
const instance = {
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
resume: vi.fn().mockReturnValue('<speak>hello</speak>'),
next: vi.fn().mockImplementation(() => ttsNextReturns.shift()),
prev: vi.fn().mockReturnValue(undefined),
from: vi.fn().mockReturnValue('<speak>from</speak>'),
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<Document>;
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<typeof vi.fn<(sectionIndex: number) => Promise<void>>>;
beforeEach(async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
mockTtsInstances.length = 0;
ttsNextReturns = [];
onSectionChange = vi.fn<(sectionIndex: number) => Promise<void>>().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 = ['<speak>next</speak>'];
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<typeof vi.fn>).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<Document>((resolve) => {
releaseDoc = resolve;
});
const { view: newView } = makeView({ createDocument: () => gated, rendered: false });
const anchorSpy = vi.fn().mockReturnValue(new Range());
(newView.resolveCFI as ReturnType<typeof vi.fn>).mockReturnValue({
index: 0,
anchor: anchorSpy,
});
const getCFISpy = newView.getCFI as ReturnType<typeof vi.fn>;
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<Document>((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();
});
});
@@ -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<TTSMessageEvent> {
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<string, unknown>) {
Object.assign(this, makeMockClient('web-speech'));
}),
}));
vi.mock('@/services/tts/EdgeTTSClient', () => ({
EdgeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, makeMockClient('edge-tts'));
}),
}));
vi.mock('@/services/tts/NativeTTSClient', () => ({
NativeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
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<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
next: ReturnType<typeof vi.fn>;
prev: ReturnType<typeof vi.fn>;
from: ReturnType<typeof vi.fn>;
setMark: ReturnType<typeof vi.fn>;
getLastRange: ReturnType<typeof vi.fn>;
doc: Document | null;
}
const mockTtsInstances: MockTtsInstance[] = [];
vi.mock('foliate-js/tts.js', () => ({
TTS: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
const instance = {
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
resume: vi.fn().mockReturnValue('<speak>hello</speak>'),
next: vi.fn().mockImplementation(() => ttsNextReturns.shift()),
prev: vi.fn().mockReturnValue(undefined),
from: vi.fn().mockReturnValue('<speak>from</speak>'),
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 = ['<speak>next</speak>'];
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<typeof vi.fn>).mockImplementation(
async function* (): AsyncIterable<TTSMessageEvent> {
yield { code: 'boundary', message: 'chunk', mark: '0' };
},
);
ttsNextReturns = ['<speak>next</speak>'];
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<typeof vi.fn>).mockImplementation(
async function* (): AsyncIterable<TTSMessageEvent> {
yield { code: 'boundary', message: 'chunk', mark: '0' };
},
);
await controller.speak('<speak>again</speak>');
await flushMicrotasks();
expect(controller.terminated).toBe(false);
});
});
@@ -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<string, (details: MediaSessionActionDetails) => void>;
setActionHandler: ReturnType<typeof vi.fn>;
setPositionState: ReturnType<typeof vi.fn>;
}
const makeFakeMediaSession = (): FakeWebMediaSession => {
const handlers = new Map<string, (details: MediaSessionActionDetails) => 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);
});
});
@@ -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');
});
});
@@ -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<TTSSession | null>(() =>
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 (
<div
role='status'
aria-label={`${_('Reading aloud')}: ${title}`}
className={clsx(
'fixed bottom-0 start-1/2 z-40 -translate-x-1/2 rtl:translate-x-1/2',
'motion-safe:transition-all motion-safe:duration-200',
entered ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{ paddingBottom: `${(safeAreaInsets?.bottom ?? 0) + 16}px` }}
>
<div
role='button'
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
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
<img src={coverImageUrl} alt='' className='h-8 w-8 shrink-0 rounded-full object-cover' />
) : null}
<span className='min-w-0 flex-1 truncate text-sm'>{title}</span>
{timerLabel && (
<span className='shrink-0 text-xs tabular-nums opacity-70'>{timerLabel}</span>
)}
<button
type='button'
className='touch-target shrink-0 p-1 focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none'
aria-label={isPlaying ? _('Pause') : _('Play')}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
{isPlaying ? <MdPauseCircleFilled size={28} /> : <MdPlayCircleFilled size={28} />}
</button>
<button
type='button'
className='touch-target shrink-0 p-1 focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none'
aria-label={_('Stop reading aloud')}
onClick={(e) => {
e.stopPropagation();
handleStop();
}}
>
<MdClose size={22} />
</button>
</div>
</div>
);
};
export default NowPlayingBar;
@@ -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)',
}}
>
<DropIndicator />
@@ -1664,6 +1670,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
</div>
))}
<NowPlayingBar isSelectMode={isSelectMode} />
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
@@ -150,12 +150,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ 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));
}
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const followingTTSLocationRef = useRef(true);
const sectionChangingTimestampRef = useRef(0);
@@ -67,14 +64,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const [ttsController, setTtsController] = useState<TTSController | null>(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<boolean> => {
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<TTSMark>).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,
@@ -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<TauriMediaSession | MediaSession | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(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,
};
};
@@ -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<string>;
onSectionChange?: (sectionIndex: number) => Promise<void>;
}
// 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<void> {
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<boolean>, 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<string>, 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();
@@ -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<typeof setTimeout> | 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<void> {
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();
@@ -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';
@@ -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<void> | 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<void> {
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<TTSMark>).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<void> {
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<void> {
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<void> {
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();