forked from akai/readest
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user