From a712100cfcc6be121ef1400ef1166a51f273db98 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Fri, 14 Nov 2025 13:07:37 +0530 Subject: [PATCH] feat: TTS Improvements Closes #1500 Closes #1210 Closes #1244 Closes #1110 --- App.tsx | 53 +++++++- android/app/src/main/assets/css/index.css | 14 ++- android/app/src/main/assets/css/tts.css | 17 ++- android/app/src/main/assets/js/core.js | 87 +++++++++++-- .../reader/components/WebViewReader.tsx | 117 ++++++++++++++++++ src/utils/ttsNotification.ts | 87 +++++++++++++ 6 files changed, 360 insertions(+), 15 deletions(-) create mode 100644 src/utils/ttsNotification.ts diff --git a/App.tsx b/App.tsx index edcb7144..ecefb83c 100644 --- a/App.tsx +++ b/App.tsx @@ -22,14 +22,47 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; Notifications.setNotificationHandler({ handleNotification: async () => { return { - shouldPlaySound: true, - shouldSetBadge: true, + shouldPlaySound: false, + shouldSetBadge: false, shouldShowBanner: true, shouldShowList: true, }; }, }); +Notifications.setNotificationCategoryAsync('TTS_CONTROLS', [ + { + identifier: 'TTS_PLAY_PAUSE', + buttonTitle: '▶️ Play', + options: { + opensAppToForeground: false, + }, + }, + { + identifier: 'TTS_STOP', + buttonTitle: '⏹️ Stop', + options: { + opensAppToForeground: false, + }, + }, + { + identifier: 'TTS_NEXT', + buttonTitle: '⏭️ Next', + options: { + opensAppToForeground: false, + }, + }, +]); + +Notifications.setNotificationChannelAsync('tts-controls', { + name: 'TTS Controls', + description: 'Text-to-Speech playback controls', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [], + enableLights: false, + enableVibrate: false, +}); + const App = () => { const { isDbReady, dbError, retryInitialization } = useDatabaseInitialization(); @@ -40,6 +73,22 @@ const App = () => { } }, [isDbReady, dbError]); + useEffect(() => { + const subscription = Notifications.addNotificationResponseReceivedListener( + response => { + const actionIdentifier = response.actionIdentifier; + if (actionIdentifier.startsWith('TTS_')) { + const { setTTSAction } = require('@utils/ttsNotification'); + setTTSAction(actionIdentifier); + } + }, + ); + + return () => { + subscription.remove(); + }; + }, []); + if (dbError) { return ; } diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 6b3035b8..50a31c03 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -146,10 +146,18 @@ td { text-align: right; } -.highlight, +.highlight { + color: var(--readerSettings-textColor) !important; + background-color: color-mix( + in srgb, + var(--readerSettings-textColor) 20%, + var(--readerSettings-theme) + ); +} + .highlight * { - color: var(--theme-onSecondary) !important; - background-color: var(--theme-secondary); + color: var(--readerSettings-textColor) !important; + background-color: transparent; } #Image-Modal { diff --git a/android/app/src/main/assets/css/tts.css b/android/app/src/main/assets/css/tts.css index a8a828cc..91244534 100644 --- a/android/app/src/main/assets/css/tts.css +++ b/android/app/src/main/assets/css/tts.css @@ -6,15 +6,26 @@ } #TTS-Controller button { - background: var(--theme-surface-0-9); + background: color-mix( + in srgb, + var(--readerSettings-theme) 85%, + var(--readerSettings-textColor) 15% + ); outline: none; - border-width: 0; + border-width: 1px; + border-style: solid; + border-color: color-mix( + in srgb, + var(--readerSettings-textColor) 30%, + var(--readerSettings-theme) 70% + ); display: flex; justify-items: center; align-items: center; padding: 4px; border-radius: 100%; transition: 0.5s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } #TTS-Controller.active { @@ -26,7 +37,7 @@ } #TTS-Controller svg { - fill: var(--theme-onSurface); + fill: var(--readerSettings-textColor); width: 20px; height: 20px; } diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 4b09eca3..2087ac15 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -142,7 +142,7 @@ window.tts = new (function () { this.reading = false; this.readable = element => { - ele = element ?? this.currentElement; + const ele = element ?? this.currentElement; if ( ele.nodeName !== 'SPAN' && this.readableNodeNames.includes(ele.nodeName) @@ -160,6 +160,14 @@ window.tts = new (function () { return true; }; + this.normalizeText = text => { + if (!text) return ''; + return text + .replace(/\s+/g, ' ') + .replace(/\s*([.,!?;:])\s*/g, '$1 ') + .trim(); + }; + // if can find a readable node, else stop tts this.findNextTextNode = () => { if (this.currentElement.isSameNode(reader.chapterElement) && this.started) { @@ -204,13 +212,23 @@ window.tts = new (function () { try { this.currentElement?.classList?.remove('highlight'); if (this.findNextTextNode()) { - this.reading = true; - this.speak(); + const text = this.normalizeText(this.currentElement?.innerText); + if (text) { + this.reading = true; + this.speak(); + } else { + this.next(); + } } else { this.reading = false; this.stop(); - document.getElementById('TTS-Controller').firstElementChild.innerHTML = - volumnIcon; + const controller = document.getElementById('TTS-Controller'); + if (controller?.firstElementChild) { + controller.firstElementChild.innerHTML = volumnIcon; + } + if (reader.nextChapter) { + reader.post({ type: 'next', autoStartTTS: true }); + } } } catch (e) { alert(e); @@ -240,6 +258,7 @@ window.tts = new (function () { this.pause = () => { this.reading = false; reader.post({ type: 'stop-speak' }); + reader.post({ type: 'tts-state', data: { isReading: false } }); }; this.stop = () => { @@ -249,12 +268,59 @@ window.tts = new (function () { this.currentElement = reader.chapterElement; this.started = false; this.reading = false; + reader.post({ type: 'tts-state', data: { isReading: false } }); + }; + + this.isElementInViewport = element => { + if (!element) return false; + const rect = element.getBoundingClientRect(); + const windowHeight = + window.innerHeight || document.documentElement.clientHeight; + const windowWidth = + window.innerWidth || document.documentElement.clientWidth; + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= windowHeight && + rect.right <= windowWidth + ); + }; + + this.scrollToElement = element => { + if (!element) return; + // Check if element is partially visible (at least some part is in viewport) + const rect = element.getBoundingClientRect(); + const windowHeight = + window.innerHeight || document.documentElement.clientHeight; + const isPartiallyVisible = + rect.top < windowHeight && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.right > 0; + + // Only scroll if element is not visible or barely visible + if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + } }; this.speak = () => { + if (!this.currentElement) return; this.prevElement = this.currentElement; + this.scrollToElement(this.currentElement); this.currentElement.classList.add('highlight'); - reader.post({ type: 'speak', data: this.currentElement?.innerText }); + const text = this.normalizeText(this.currentElement.innerText); + if (text) { + reader.post({ type: 'speak', data: text }); + reader.post({ type: 'tts-state', data: { isReading: true } }); + } else { + this.next(); + } }; })(); @@ -561,7 +627,14 @@ window.addEventListener('load', () => { .replace( /
\s*
[^]+/, _ => - `${/\/p>/.test(_) ? _.replace(/
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p\b[^>]*>
\s*
))\s*/g, '') : _}`, + `${ + /\/p>/.test(_) + ? _.replace( + /
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p\b[^>]*>
\s*
))\s*/g, + '', + ) + : _ + }`, ) //if p found, delete all double br near p .replace(/
(?:(?=\s*<\/?p[> ])|(?<=<\/?p>\s*
))\s*/g, ''); } diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 63f7b6a6..4d063974 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -20,10 +20,18 @@ import { getBatteryLevelSync } from 'react-native-device-info'; import * as Speech from 'expo-speech'; import { PLUGIN_STORAGE } from '@utils/Storages'; import { useChapterContext } from '../ChapterContext'; +import { + showTTSNotification, + updateTTSNotification, + dismissTTSNotification, + getTTSAction, + clearTTSAction, +} from '@utils/ttsNotification'; type WebViewPostEvent = { type: string; data?: { [key: string]: string | number }; + autoStartTTS?: boolean; }; type WebViewReaderProps = { @@ -80,6 +88,64 @@ const WebViewReader: React.FC = ({ onPress }) => { const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`; const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`; const nextChapterScreenVisible = useRef(false); + const autoStartTTSRef = useRef(false); + const isTTSReadingRef = useRef(false); + + useEffect(() => { + const checkNotificationActions = setInterval(() => { + const action = getTTSAction(); + if (action) { + clearTTSAction(); + switch (action) { + case 'TTS_PLAY_PAUSE': + webViewRef.current?.injectJavaScript(` + if (window.tts) { + if (tts.reading) { + tts.pause(); + } else { + tts.resume(); + } + } + `); + break; + case 'TTS_STOP': + webViewRef.current?.injectJavaScript(` + if (window.tts) { + tts.stop(); + } + `); + break; + case 'TTS_NEXT': + webViewRef.current?.injectJavaScript(` + if (window.tts && window.reader && window.reader.nextChapter) { + window.reader.post({ type: 'next', autoStartTTS: true }); + } + `); + break; + } + } + }, 500); + + return () => { + clearInterval(checkNotificationActions); + }; + }, [webViewRef]); + + useEffect(() => { + if (isTTSReadingRef.current) { + updateTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + isPlaying: isTTSReadingRef.current, + }); + } + }, [novel?.name, chapter.name]); + + useEffect(() => { + return () => { + dismissTTSNotification(); + }; + }, []); useEffect(() => { const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { @@ -124,6 +190,26 @@ const WebViewReader: React.FC = ({ onPress }) => { showsVerticalScrollIndicator={false} javaScriptEnabled={true} webviewDebuggingEnabled={__DEV__} + onLoadEnd={() => { + if (autoStartTTSRef.current) { + autoStartTTSRef.current = false; + setTimeout(() => { + webViewRef.current?.injectJavaScript(` + (function() { + if (window.tts && reader.generalSettings.val.TTSEnable) { + setTimeout(() => { + tts.start(); + const controller = document.getElementById('TTS-Controller'); + if (controller && controller.firstElementChild) { + controller.firstElementChild.innerHTML = pauseIcon; + } + }, 500); + } + })(); + `); + }, 300); + } + }} onMessage={(ev: { nativeEvent: { data: string } }) => { __DEV__ && onLogMessage(ev); const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); @@ -133,6 +219,9 @@ const WebViewReader: React.FC = ({ onPress }) => { break; case 'next': nextChapterScreenVisible.current = true; + if (event.autoStartTTS) { + autoStartTTSRef.current = true; + } navigateChapter('NEXT'); break; case 'prev': @@ -145,6 +234,20 @@ const WebViewReader: React.FC = ({ onPress }) => { break; case 'speak': if (event.data && typeof event.data === 'string') { + if (!isTTSReadingRef.current) { + isTTSReadingRef.current = true; + showTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + isPlaying: true, + }); + } else { + updateTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + isPlaying: true, + }); + } Speech.speak(event.data, { onDone() { webViewRef.current?.injectJavaScript('tts.next?.()'); @@ -159,6 +262,20 @@ const WebViewReader: React.FC = ({ onPress }) => { break; case 'stop-speak': Speech.stop(); + isTTSReadingRef.current = false; + dismissTTSNotification(); + break; + case 'tts-state': + if (event.data && typeof event.data === 'object') { + const data = event.data as { isReading?: boolean }; + const isReading = data.isReading === true; + isTTSReadingRef.current = isReading; + updateTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + isPlaying: isReading, + }); + } break; } }} diff --git a/src/utils/ttsNotification.ts b/src/utils/ttsNotification.ts new file mode 100644 index 00000000..501e9544 --- /dev/null +++ b/src/utils/ttsNotification.ts @@ -0,0 +1,87 @@ +import * as Notifications from 'expo-notifications'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; + +const TTS_NOTIFICATION_ID = 'tts-control'; +const TTS_ACTION_KEY = 'TTS_NOTIFICATION_ACTION'; + +export const updateTTSCategory = async (isPlaying: boolean) => { + await Notifications.setNotificationCategoryAsync('TTS_CONTROLS', [ + { + identifier: 'TTS_PLAY_PAUSE', + buttonTitle: isPlaying ? '⏸️ Pause' : '▶️ Play', + options: { + opensAppToForeground: false, + }, + }, + { + identifier: 'TTS_STOP', + buttonTitle: '⏹️ Stop', + options: { + opensAppToForeground: false, + }, + }, + { + identifier: 'TTS_NEXT', + buttonTitle: '⏭️ Next', + options: { + opensAppToForeground: false, + }, + }, + ]); +}; + +export interface TTSNotificationData { + novelName: string; + chapterName: string; + isPlaying: boolean; +} + +export const showTTSNotification = async (data: TTSNotificationData) => { + await updateTTSCategory(data.isPlaying); + await Notifications.scheduleNotificationAsync({ + identifier: TTS_NOTIFICATION_ID, + content: { + title: data.novelName, + subtitle: data.chapterName, + body: data.isPlaying ? 'Playing' : 'Paused', + categoryIdentifier: 'TTS_CONTROLS', + sticky: true, + sound: false, + priority: Notifications.AndroidNotificationPriority.HIGH, + }, + trigger: null, + }); +}; + +export const updateTTSNotification = async (data: TTSNotificationData) => { + await updateTTSCategory(data.isPlaying); + await Notifications.scheduleNotificationAsync({ + identifier: TTS_NOTIFICATION_ID, + content: { + title: data.novelName, + subtitle: data.chapterName, + body: data.isPlaying ? 'Playing' : 'Paused', + categoryIdentifier: 'TTS_CONTROLS', + sticky: true, + sound: false, + priority: Notifications.AndroidNotificationPriority.HIGH, + }, + trigger: null, + }); +}; + +export const dismissTTSNotification = async () => { + await Notifications.dismissNotificationAsync(TTS_NOTIFICATION_ID); +}; + +export const setTTSAction = (action: string) => { + MMKVStorage.set(TTS_ACTION_KEY, action); +}; + +export const getTTSAction = (): string | undefined => { + return MMKVStorage.getString(TTS_ACTION_KEY); +}; + +export const clearTTSAction = () => { + MMKVStorage.delete(TTS_ACTION_KEY); +};