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:
@@ -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();
|
||||
Reference in New Issue
Block a user