fix(tts): media control in the lock screen and with airpods for iOS, closes #1407 (#1949)
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

This commit is contained in:
Huang Xin 2025-09-02 23:24:16 +08:00 committed by GitHub
parent 61dda9a517
commit 32954e025c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 76 additions and 33 deletions

View file

@ -123,6 +123,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
gridInsets={gridInsets}
contentInsets={contentInsets}
/>
{viewSettings.vertical && viewSettings.scrolled && (

View file

@ -58,16 +58,18 @@ const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
gridInsets: Insets;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
}> = ({ bookKey, bookDoc, config, gridInsets, contentInsets: insets }) => {
const { appService, envConfig } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { loadCustomFonts, getLoadedFonts } = useCustomFontStore();
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
@ -308,6 +310,7 @@ const FoliateViewer: React.FC<{
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewState = getViewState(bookKey);
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
@ -315,7 +318,11 @@ const FoliateViewer: React.FC<{
const showTopHeader = viewSettings.showHeader && !viewSettings.vertical;
const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical;
const moreTopInset = showTopHeader ? Math.max(0, 44 - insets.top) : 0;
const moreBottomInset = showBottomFooter ? Math.max(0, 44 - insets.bottom) : 0;
const ttsBarHeight =
viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0;
const moreBottomInset = showBottomFooter
? Math.max(0, Math.max(ttsBarHeight, 44) - insets.bottom)
: Math.max(0, ttsBarHeight);
const moreRightInset = showDoubleBorderHeader ? 32 : 0;
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
@ -379,6 +386,8 @@ const FoliateViewer: React.FC<{
viewSettings?.doubleBorder,
viewSettings?.showHeader,
viewSettings?.showFooter,
viewSettings?.showTTSBar,
viewState?.ttsEnabled,
]);
return (

View file

@ -68,11 +68,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('stop', null);
navigator.mediaSession.setActionHandler('seekbackward', null);
navigator.mediaSession.setActionHandler('seekforward', null);
}
});
unblockerAudioRef.current.preload = 'auto';
@ -127,7 +122,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const mark = (e as CustomEvent<TTSMark>).detail;
if (appService?.isMobileApp && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
@ -244,7 +239,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
};
const handleTogglePlay = async () => {
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
@ -263,41 +258,54 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
await ttsController.start();
}
}
};
}, [isPlaying, isPaused]);
const handleBackward = async () => {
const handleBackward = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
}
};
}, []);
const handleForward = async () => {
const handleForward = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
}
};
}, []);
const handleStop = async (bookKey: string) => {
const handlePause = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
setIsPaused(true);
await ttsController.pause();
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
}, []);
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -415,6 +423,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
setShowPanel(false);
};
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('stop', () => {
handlePause();
});
navigator.mediaSession.setActionHandler('seekforward', () => {
handleForward();
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
handleBackward();
});
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
useEffect(() => {
if (!iconRef.current || !showPanel) return;
const parentElement = iconRef.current.parentElement;

View file

@ -219,6 +219,7 @@ export class TTSController extends EventTarget {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
this.preloadNextSSML();
this.dispatchSpeakMark();
}
play() {
@ -357,8 +358,8 @@ export class TTSController extends EventTarget {
return this.ttsClient.getSpeakingLang();
}
dispatchSpeakMark(mark: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark }));
dispatchSpeakMark(mark?: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } }));
}
error(e: unknown) {

View file

@ -5,7 +5,7 @@ compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
head_sampling_rate = 1
head_sampling_rate = 0.01
[assets]
directory = ".open-next/assets"