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);
+};