feat(tts): keep TTS playing when the book is closed (#4941)

* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events

view.close() nulls view.tts, so the controller keeps its own handle
(mirrored to view.tts while attached; reads prefer the public mirror).
state becomes an accessor that dispatches tts-state-change on a
microtask, and terminal conditions (end of content, error exhaustion)
fire an explicit tts-session-ended: 'stopped' is a transit value that
occurs on every paragraph advance and must never be read as death.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): make TTSController detachable from the reader view

detachView enters headless mode: layout-dependent work is guarded, the
dead hook's preprocess/section-change closures are severed, and text
supply continues through created documents while position events keep
flowing. attachView adopts a new view without touching in-flight audio,
re-seeding the fresh text instance from the old cursor AT the
synchronous swap point (auto-advance during async prep would otherwise
replay a paragraph) and aborting via an attach epoch when a detach
supersedes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): move media session ownership to a session-scoped bridge

ttsMediaBridge binds directly to the controller (metadata per mark,
clamped position state, transport handlers, the silent keep-alive
element) so the lock screen keeps working when the reader hook is
unmounted. The hook's last-writer-wins handler effect and its
per-render re-registration are gone; the panel now derives isPlaying
from the controller's state channel, so lock-screen transport keeps
the in-reader UI truthful. useTTSMediaSession had no consumers left
and is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence

Sessions key by book hash (bookKey is regenerated per open), the
playback-state relay dedupes transit stopped values so paragraph
advances never flicker followers, and terminal handling rides the
explicit tts-session-ended event. The sleep timer survives reader
unmount, and headless positions persist through the book config on
disk (view/progress stores are cleared on close and reopen loads
from disk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): keep TTS playing across book close and reattach on reopen

Back-to-library and Android back dispatch tts-close-book (detach when
the session is not terminated: transit stopped states during chapter
transitions must not kill it); quit and window-destroying closes keep
the hard tts-stop so the foreground service tears down with the
webview. The unmount cleanup transfers ownership to the manager
instead of shutting down, covering deep-link book switches and
split-view pane closes. Mounting a book adopts a matching background
session once the view is ready (primary pane only) and stops a
different book's session unless it is still mounted elsewhere. The
sleep timer moves to the manager and a one-time toast announces the
first background continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): now-playing bar in the library for background sessions

Floating pill above the shelf while a TTS session outlives its
reader: cover, title, sleep-timer countdown, play/pause following the
manager-relayed playback channel, and a hard stop. Tapping the body
reopens the book in the SAME window regardless of the new-window
preference, since the session is a per-webview singleton. Deleting
the playing book stops the session before its data is cleared. The
bookshelf reserves scroll clearance via a --now-playing-inset var the
bar sets while visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): make the header close button background-eligible

The header X routes through handleCloseBook (onCloseBook), not
handleCloseBooksToLibrary, so the sticky eligibility ref never got
set and closing a book from the header hard-stopped a live TTS
session. Replace the ref with an explicit keepTTSAlive parameter on
saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android
back, and pane closes pass true; beforeunload, quit-app, and window
close invoke handleCloseBooks with an event object, which coerces to
a hard stop. This also removes the stickiness where one background
close would have made a later quit detach instead of stop.

Verified live in Chrome dev-web: close from the header keeps audio
playing with the now-playing bar shown; reopening reattaches the
same session (generation numbering continues); opening a different
book stops it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-06 00:38:57 +09:00
committed by GitHub
parent 42f9b8fe3c
commit 843ab3448b
15 changed files with 2324 additions and 421 deletions
@@ -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,
};
};