feat: TTS Improvements
Closes #1500 Closes #1210 Closes #1244 Closes #1110
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user