feat: TTS Improvements

Closes #1500
Closes #1210
Closes #1244
Closes #1110
This commit is contained in:
Rajarshee Chatterjee
2025-11-14 13:07:37 +05:30
parent 92e1210fc8
commit a712100cfc
6 changed files with 360 additions and 15 deletions
+51 -2
View File
@@ -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 <ErrorFallback error={dbError} resetError={retryInitialization} />;
}
+11 -3
View File
@@ -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 {
+14 -3
View File
@@ -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;
}
+80 -7
View File
@@ -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(
/<br>\s*<br>[^]+/,
_ =>
`${/\/p>/.test(_) ? _.replace(/<br>\s*<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p\b[^>]*><br>\s*<br>))\s*/g, '') : _}`,
`${
/\/p>/.test(_)
? _.replace(
/<br>\s*<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p\b[^>]*><br>\s*<br>))\s*/g,
'',
)
: _
}`,
) //if p found, delete all double br near p
.replace(/<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p>\s*<br>))\s*/g, '');
}
@@ -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<WebViewReaderProps> = ({ onPress }) => {
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
const nextChapterScreenVisible = useRef<boolean>(false);
const autoStartTTSRef = useRef<boolean>(false);
const isTTSReadingRef = useRef<boolean>(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<WebViewReaderProps> = ({ 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<WebViewReaderProps> = ({ 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<WebViewReaderProps> = ({ 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<WebViewReaderProps> = ({ 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;
}
}}
+87
View File
@@ -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);
};