From bae91e4a0377143c25408d999a9fb5dfe9d2d1b0 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:37:59 +0700 Subject: [PATCH 01/18] first --- src/screens/reader/ReaderScreen.tsx | 4 +- .../components/Hooks/TTSPlaybackManager.ts | 237 ++++++ .../Hooks/useReaderMessageHandler.ts | 4 + .../components/Hooks/useReaderSettings.ts | 14 +- src/screens/reader/components/Hooks/useTTS.ts | 218 ++---- .../reader/components/Hooks/useTTSPlayback.ts | 97 +++ .../ReaderBottomSheet/ReaderBottomSheet.tsx | 228 ------ .../ReaderBottomSheet/ReaderFontPicker.tsx | 68 -- .../ReaderSheetPreferenceItem.tsx | 55 -- .../ReaderBottomSheet/ReaderThemeSelector.tsx | 79 -- .../ReaderBottomSheet/ReaderValueChange.tsx | 96 --- .../components/ReaderBottomSheet/TTSTab.tsx | 732 ------------------ .../ReaderBottomSheet/TextSizeSlider.tsx | 52 -- .../ReaderBottomSheet/TranslateTab.tsx | 532 ------------- .../ReaderSettingsBottomSheet.tsx | 49 ++ .../ReaderSettings/ReaderSettingsPanel.tsx | 80 ++ .../ReaderSettings/ReaderSettingsTabBar.tsx | 82 ++ .../components/ReaderSettings/TTSVoices.ts | 119 +++ .../components/FontPickerModal.tsx | 47 ++ .../components}/ReaderTextAlignSelector.tsx | 26 +- .../components/ReaderThemeSelector.tsx | 46 ++ .../components/ReaderValueControl.tsx | 84 ++ .../components/VoicePickerModal.tsx | 152 ++++ .../ReaderSettings/tabs/AccessibilityTab.tsx | 79 ++ .../ReaderSettings/tabs/AdvancedTab.tsx | 174 +++++ .../ReaderSettings/tabs/DisplayTab.tsx | 72 ++ .../ReaderSettings/tabs/NavigationTab.tsx | 174 +++++ .../components/ReaderSettings/tabs/TTSTab.tsx | 330 ++++++++ .../ReaderSettings/tabs/ThemeTab.tsx | 102 +++ .../ReaderWebView/ReaderWebViewCore.tsx | 43 + .../reader/components/WebViewReader.tsx | 181 +++-- .../reader/utils/readerWebViewConfig.ts | 18 + .../Modals/FontPickerModal.tsx | 68 -- .../Modals/VoicePickerModal.tsx | 97 --- .../ReaderPreviewWebView.tsx | 178 +++++ .../SettingsReaderScreen/ReaderTextSize.tsx | 61 -- .../SettingsReaderScreen.tsx | 288 +------ .../components/TabBar.tsx | 72 -- .../SettingsReaderScreen/previewFixture.ts | 38 + .../tabs/AccessibilityTab.tsx | 101 --- .../SettingsReaderScreen/tabs/AdvancedTab.tsx | 390 ---------- .../SettingsReaderScreen/tabs/DisplayTab.tsx | 92 --- .../tabs/NavigationTab.tsx | 229 ------ .../SettingsReaderScreen/tabs/ThemeTab.tsx | 143 ---- strings/languages/en/strings.json | 41 +- strings/languages/vi_VN/strings.json | 41 +- strings/types/index.ts | 35 + 47 files changed, 2521 insertions(+), 3627 deletions(-) create mode 100644 src/screens/reader/components/Hooks/TTSPlaybackManager.ts create mode 100644 src/screens/reader/components/Hooks/useTTSPlayback.ts delete mode 100644 src/screens/reader/components/ReaderBottomSheet/ReaderBottomSheet.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/ReaderSheetPreferenceItem.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/ReaderThemeSelector.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/TextSizeSlider.tsx delete mode 100644 src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/ReaderSettingsBottomSheet.tsx create mode 100644 src/screens/reader/components/ReaderSettings/ReaderSettingsPanel.tsx create mode 100644 src/screens/reader/components/ReaderSettings/ReaderSettingsTabBar.tsx create mode 100644 src/screens/reader/components/ReaderSettings/TTSVoices.ts create mode 100644 src/screens/reader/components/ReaderSettings/components/FontPickerModal.tsx rename src/screens/reader/components/{ReaderBottomSheet => ReaderSettings/components}/ReaderTextAlignSelector.tsx (70%) create mode 100644 src/screens/reader/components/ReaderSettings/components/ReaderThemeSelector.tsx create mode 100644 src/screens/reader/components/ReaderSettings/components/ReaderValueControl.tsx create mode 100644 src/screens/reader/components/ReaderSettings/components/VoicePickerModal.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/AccessibilityTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/AdvancedTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/DisplayTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/NavigationTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/TTSTab.tsx create mode 100644 src/screens/reader/components/ReaderSettings/tabs/ThemeTab.tsx create mode 100644 src/screens/reader/components/ReaderWebView/ReaderWebViewCore.tsx create mode 100644 src/screens/reader/utils/readerWebViewConfig.ts delete mode 100644 src/screens/settings/SettingsReaderScreen/Modals/FontPickerModal.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx create mode 100644 src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/components/TabBar.tsx create mode 100644 src/screens/settings/SettingsReaderScreen/previewFixture.ts delete mode 100644 src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/tabs/DisplayTab.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx delete mode 100644 src/screens/settings/SettingsReaderScreen/tabs/ThemeTab.tsx diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 81805a18ab..865301c64e 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -17,8 +17,8 @@ import ChapterLoadingScreen from './ChapterLoadingScreen/ChapterLoadingScreen'; import ChapterDrawer from './components/ChapterDrawer'; import KeepScreenAwake from './components/KeepScreenAwake'; import ReaderAppbar from './components/ReaderAppbar'; -import ReaderBottomSheetV2 from './components/ReaderBottomSheet/ReaderBottomSheet'; import ReaderFooter from './components/ReaderFooter'; +import ReaderSettingsBottomSheet from './components/ReaderSettings/ReaderSettingsBottomSheet'; import WebViewReader from './components/WebViewReader'; import { useNativeChapterSearch } from './hooks/useNativeChapterSearch'; @@ -196,7 +196,7 @@ export const ChapterContent = ({ /> - + {!hidden && ( ; + +export type TTSPlaybackError = + | 'tiktok-unavailable' + | 'voice-required' + | 'playback-error'; + +export type TTSPlaybackCallbacks = { + onWillPlay?: () => void; + onStart?: () => void; + onDone?: () => void; + onError?: (error: TTSPlaybackError, message?: string) => void; + onInterrupted?: () => void; +}; + +type ActiveSession = { + callbacks: TTSPlaybackCallbacks; + engine: 'native' | 'tiktok'; + id: symbol; + playbackId: symbol; + started: boolean; +}; + +const { TikTokTTS } = NativeModules; + +class TTSPlaybackManager { + private activeSession: ActiveSession | null = null; + + constructor() { + if (!TikTokTTS) return; + + const emitter = new NativeEventEmitter(TikTokTTS); + emitter.addListener('TikTokTTS_onStart', () => { + if (this.activeSession?.engine !== 'tiktok') return; + this.activeSession.started = true; + this.activeSession.callbacks.onStart?.(); + }); + emitter.addListener('TikTokTTS_onDone', () => { + if ( + this.activeSession?.engine !== 'tiktok' || + !this.activeSession.started + ) { + return; + } + const session = this.activeSession; + this.activeSession = null; + session?.callbacks.onDone?.(); + }); + emitter.addListener('TikTokTTS_onError', (error: { message?: string }) => { + if (this.activeSession?.engine !== 'tiktok') return; + const session = this.activeSession; + this.activeSession = null; + session?.callbacks.onError?.('playback-error', error.message); + }); + } + + speak( + id: symbol, + text: string, + settings: TTSSettings, + callbacks: TTSPlaybackCallbacks, + ): boolean { + const engine = settings.engine ?? 'native'; + const voice = isTikTokVoice(settings.voice) + ? engine === 'tiktok' + ? settings.voice?.identifier + : undefined + : engine === 'native' + ? settings.voice?.identifier + : undefined; + + if (engine === 'tiktok') { + if (!TikTokTTS) { + callbacks.onError?.('tiktok-unavailable'); + return false; + } + if (!voice) { + callbacks.onError?.('voice-required'); + return false; + } + } + + const playbackId = Symbol('tts-playback'); + this.activate(id, playbackId, engine, callbacks); + callbacks.onWillPlay?.(); + + try { + if (engine === 'tiktok') { + const result = TikTokTTS.speak( + text, + voice, + settings.queueSize || 3, + settings.rate || 1, + settings.pitch || 1, + ); + if (result && typeof result.catch === 'function') { + result.catch((error: unknown) => + this.fail( + id, + playbackId, + error instanceof Error ? error.message : String(error), + ), + ); + } + return true; + } + + Speech.speak(text, { + onStart: () => this.start(id, playbackId), + onDone: () => this.finish(id, playbackId), + onError: error => this.fail(id, playbackId, error.message), + voice, + pitch: settings.pitch || 1, + rate: settings.rate || 1, + }); + return true; + } catch (error) { + this.fail( + id, + playbackId, + error instanceof Error ? error.message : String(error), + ); + return false; + } + } + + pause(id: symbol) { + if (this.activeSession?.id !== id) return; + if (this.activeSession.engine === 'tiktok') { + TikTokTTS?.pause(); + return; + } + this.activeSession = null; + Speech.stop(); + } + + stop(id: symbol) { + if (this.activeSession?.id !== id) return; + this.stopNativeEngines(); + this.activeSession = null; + } + + stopAll() { + const session = this.activeSession; + this.stopNativeEngines(); + this.activeSession = null; + session?.callbacks.onInterrupted?.(); + } + + updateQueue(id: symbol, queue: string[], settings: TTSSettings) { + const { voice } = settings; + if ( + this.activeSession?.id !== id || + settings.engine !== 'tiktok' || + !voice?.identifier || + !isTikTokVoice(voice) + ) { + return; + } + try { + TikTokTTS?.updateQueue(queue, voice.identifier); + } catch { + // Queue preloading is optional; the current utterance can continue. + } + } + + private activate( + id: symbol, + playbackId: symbol, + engine: 'native' | 'tiktok', + callbacks: TTSPlaybackCallbacks, + ) { + const previous = this.activeSession; + if (previous) { + this.stopNativeEngines(); + this.activeSession = null; + if (previous.id !== id) { + previous.callbacks.onInterrupted?.(); + } + } + this.activeSession = { + callbacks, + engine, + id, + playbackId, + started: engine === 'native', + }; + } + + private finish(id: symbol, playbackId: symbol) { + if ( + this.activeSession?.id !== id || + this.activeSession.playbackId !== playbackId + ) { + return; + } + const session = this.activeSession; + this.activeSession = null; + session.callbacks.onDone?.(); + } + + private start(id: symbol, playbackId: symbol) { + if ( + this.activeSession?.id !== id || + this.activeSession.playbackId !== playbackId + ) { + return; + } + this.activeSession.started = true; + this.activeSession.callbacks.onStart?.(); + } + + private fail(id: symbol, playbackId: symbol, message?: string) { + if ( + this.activeSession?.id !== id || + this.activeSession.playbackId !== playbackId + ) { + return; + } + const session = this.activeSession; + this.activeSession = null; + session.callbacks.onError?.('playback-error', message); + } + + private stopNativeEngines() { + Speech.stop(); + TikTokTTS?.stop(); + } +} + +export default new TTSPlaybackManager(); diff --git a/src/screens/reader/components/Hooks/useReaderMessageHandler.ts b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts index 5b719ccd78..84801c779d 100644 --- a/src/screens/reader/components/Hooks/useReaderMessageHandler.ts +++ b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts @@ -12,6 +12,7 @@ type UseReaderMessageHandlerOptions = { onFindResult: (result: NativeFindResult) => void; navigateChapter: (direction: 'NEXT' | 'PREV') => void; saveProgress: (progress: number) => void; + onProgress?: (progress: number) => void; resetAutoScroll: () => void; refetch: () => void; tts: TTSController; @@ -25,6 +26,7 @@ export default function useReaderMessageHandler({ onFindResult, navigateChapter, saveProgress, + onProgress, resetAutoScroll, refetch, tts, @@ -79,6 +81,7 @@ export default function useReaderMessageHandler({ break; case 'save': if (typeof event.data === 'number') { + onProgress?.(event.data); saveProgress(event.data); } break; @@ -147,6 +150,7 @@ export default function useReaderMessageHandler({ navigateChapter, onFindResult, onPress, + onProgress, refetch, resetAutoScroll, saveProgress, diff --git a/src/screens/reader/components/Hooks/useReaderSettings.ts b/src/screens/reader/components/Hooks/useReaderSettings.ts index a98d805cf8..b095a8b58a 100644 --- a/src/screens/reader/components/Hooks/useReaderSettings.ts +++ b/src/screens/reader/components/Hooks/useReaderSettings.ts @@ -26,21 +26,23 @@ export const useReaderSettingsBridge = ({ }: UseReaderSettingsBridgeOptions) => { const readerSettingsMountedRef = useRef(false); const generalSettingsMountedRef = useRef(false); + const previousTTSRef = useRef(readerSettings.tts); useEffect(() => { if (!readerSettingsMountedRef.current) { readerSettingsMountedRef.current = true; return; } - stopNativePlayback(); + const ttsChanged = + JSON.stringify(previousTTSRef.current) !== + JSON.stringify(readerSettings.tts); + if (ttsChanged) { + stopNativePlayback(); + } + previousTTSRef.current = readerSettings.tts; webViewRef.current?.injectJavaScript(` if (window.reader?.readerSettings) { reader.readerSettings.val = ${JSON.stringify(readerSettings)}; - if (window.tts && tts.reading) { - const currentElement = tts.currentElement; - tts.stop(); - setTimeout(() => tts.start(currentElement), 100); - } }`); }, [readerSettings, stopNativePlayback, webViewRef]); diff --git a/src/screens/reader/components/Hooks/useTTS.ts b/src/screens/reader/components/Hooks/useTTS.ts index 7c1535044b..cf22b115b6 100644 --- a/src/screens/reader/components/Hooks/useTTS.ts +++ b/src/screens/reader/components/Hooks/useTTS.ts @@ -1,5 +1,6 @@ import type { ChapterInfo, NovelInfo } from '@database/types'; import type { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { getString } from '@strings/translations'; import { showToast } from '@utils/showToast'; import { dismissTTSNotification, @@ -9,21 +10,14 @@ import { updateTTSPlaybackState, updateTTSProgress, } from '@utils/ttsNotification'; -import * as Speech from 'expo-speech'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { AppState, NativeEventEmitter, NativeModules } from 'react-native'; +import { AppState } from 'react-native'; import type WebView from 'react-native-webview'; import type useReadingTime from './useReadingTime'; +import useTTSPlayback from './useTTSPlayback'; import type { WebViewPostEvent } from './webViewEvents'; -const { TikTokTTS } = NativeModules; -const tiktokTTSEmitter = TikTokTTS ? new NativeEventEmitter(TikTokTTS) : null; -const stopNativeEngines = () => { - Speech.stop(); - TikTokTTS?.stop(); -}; - type ReadingTimeController = ReturnType; type UseTTSOptions = { @@ -42,8 +36,6 @@ export default function useTTS({ readingTime, }: UseTTSOptions) { const autoStartTTSRef = useRef(false); - const ttsQueueRef = useRef([]); - const ttsQueueIndexRef = useRef(0); const appStateRef = useRef(AppState.currentState); const autoStartTimerRef = useRef | null>(null); const { isTTSReadingRef } = readingTime; @@ -65,8 +57,6 @@ export default function useTTS({ const resetAfterPlaybackError = useCallback(() => { autoStartTTSRef.current = false; clearAutoStartTimer(); - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; if (appStateRef.current === 'active') { readingTime.setTTSReading(false); } else { @@ -76,6 +66,34 @@ export default function useTTS({ webViewRef.current?.injectJavaScript('tts.stop?.()'); }, [clearAutoStartTimer, readingTime, webViewRef]); + const handlePlaybackError = useCallback( + (error: 'tiktok-unavailable' | 'voice-required' | 'playback-error') => { + if (error === 'tiktok-unavailable') { + showToast(getString('readerSettings.tts.engineUnavailableError')); + } else if (error === 'voice-required') { + showToast(getString('readerSettings.tts.voiceRequiredError')); + } else { + showToast(getString('readerSettings.tts.playbackError')); + } + webViewRef.current?.injectJavaScript('tts.setLoading(false)'); + resetAfterPlaybackError(); + }, + [resetAfterPlaybackError, webViewRef], + ); + + const playback = useTTSPlayback({ + readerSettingsRef, + onStart: () => webViewRef.current?.injectJavaScript('tts.setLoading(true)'), + onDone: () => { + webViewRef.current?.injectJavaScript('tts.setLoading(false)'); + if (appStateRef.current === 'active') { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + }, + onError: handlePlaybackError, + onInterrupted: resetAfterPlaybackError, + }); + useEffect(() => { const inject = (script: string) => webViewRef.current?.injectJavaScript(script); @@ -142,103 +160,14 @@ export default function useTTS({ readingTime.pause(); if (isTTSReadingRef.current) { - stopNativeEngines(); + playback.stop(); readingTime.stopTTSForBackground(); - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; dismissTTSNotification(); webViewRef.current?.injectJavaScript('if (window.tts) { tts.stop(); }'); } }); return () => subscription.remove(); - }, [isTTSReadingRef, readingTime, webViewRef]); - - useEffect(() => { - if (!tiktokTTSEmitter) return; - - const onStart = tiktokTTSEmitter.addListener('TikTokTTS_onStart', () => { - webViewRef.current?.injectJavaScript('tts.setLoading(true)'); - }); - const onDone = tiktokTTSEmitter.addListener('TikTokTTS_onDone', () => { - webViewRef.current?.injectJavaScript('tts.setLoading(false)'); - if (appStateRef.current === 'active') { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - }); - const onError = tiktokTTSEmitter.addListener( - 'TikTokTTS_onError', - (error: { message?: string }) => { - webViewRef.current?.injectJavaScript('tts.setLoading(false)'); - resetAfterPlaybackError(); - // eslint-disable-next-line no-console - console.error('TikTokTTS Error:', error.message); - }, - ); - return () => { - onStart.remove(); - onDone.remove(); - onError.remove(); - }; - }, [resetAfterPlaybackError, webViewRef]); - - const speakText = useCallback( - (text: string) => { - const ttsSettings = readerSettingsRef.current?.tts; - if (ttsSettings?.engine === 'tiktok') { - const voice = ttsSettings.voice?.identifier; - if (!voice) { - showToast('TikTok TTS: No voice selected'); - return; - } - TikTokTTS?.speak( - text, - voice, - ttsSettings.queueSize || 3, - ttsSettings.rate || 1, - ttsSettings.pitch || 1, - ); - return; - } - - Speech.speak(text, { - onDone() { - if (appStateRef.current === 'active') { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - }, - onError: resetAfterPlaybackError, - voice: ttsSettings?.voice?.identifier, - pitch: ttsSettings?.pitch || 1, - rate: ttsSettings?.rate || 1, - }); - }, - [readerSettingsRef, resetAfterPlaybackError, webViewRef], - ); - - const handleQueue = useCallback( - (event: WebViewPostEvent) => { - const payload = event.data as - | { queue?: unknown; startIndex?: unknown } - | undefined; - const queue = Array.isArray(payload?.queue) - ? payload.queue.filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ) - : []; - ttsQueueRef.current = queue; - ttsQueueIndexRef.current = - typeof payload?.startIndex === 'number' ? payload.startIndex : 0; - - if (readerSettingsRef.current?.tts?.engine === 'tiktok') { - const voice = readerSettingsRef.current.tts.voice?.identifier; - if (voice) { - TikTokTTS?.updateQueue(queue.slice(ttsQueueIndexRef.current), voice); - } - } - }, - [readerSettingsRef], - ); + }, [isTTSReadingRef, playback, readingTime, webViewRef]); const handleSpeak = useCallback( (event: WebViewPostEvent) => { @@ -246,74 +175,35 @@ export default function useTTS({ webViewRef.current?.injectJavaScript('tts.next?.()'); return; } - - const ttsSettings = readerSettingsRef.current?.tts; - if (ttsSettings?.engine === 'tiktok') { - if (!TikTokTTS) { - showToast('TikTok TTS is unavailable'); - resetAfterPlaybackError(); - return; + playback.handleSpeak(event, () => { + if (!isTTSReadingRef.current) { + readingTime.setTTSReading(true); + showTTSNotification(notificationInfo); + } else { + updateTTSNotification(notificationInfo); } - if (!ttsSettings.voice?.identifier) { - showToast('TikTok TTS: No voice selected'); - resetAfterPlaybackError(); - return; + if ( + typeof event.index === 'number' && + typeof event.total === 'number' && + event.total > 0 + ) { + updateTTSProgress(event.index, event.total); } - } - - if (typeof event.index === 'number') { - ttsQueueIndexRef.current = event.index; - } - if (!isTTSReadingRef.current) { - readingTime.setTTSReading(true); - showTTSNotification(notificationInfo); - } else { - updateTTSNotification(notificationInfo); - } - if ( - typeof event.index === 'number' && - typeof event.total === 'number' && - event.total > 0 - ) { - updateTTSProgress(event.index, event.total); - } - if (readerSettingsRef.current?.tts?.engine === 'tiktok') { - const voice = readerSettingsRef.current.tts.voice?.identifier; - if (voice) { - TikTokTTS?.updateQueue( - ttsQueueRef.current.slice(ttsQueueIndexRef.current + 1), - voice, - ); - } - } - speakText(event.data); + }); }, - [ - isTTSReadingRef, - notificationInfo, - readerSettingsRef, - readingTime, - resetAfterPlaybackError, - speakText, - webViewRef, - ], + [isTTSReadingRef, notificationInfo, playback, readingTime, webViewRef], ); - const handlePause = useCallback(() => { - Speech.stop(); - TikTokTTS?.pause(); - }, []); + const handlePause = playback.pause; const handleStop = useCallback(() => { - stopNativeEngines(); + playback.stop(); if (!autoStartTTSRef.current) { clearAutoStartTimer(); readingTime.setTTSReading(false); - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; dismissTTSNotification(); } - }, [clearAutoStartTimer, readingTime]); + }, [clearAutoStartTimer, playback, readingTime]); const handleState = useCallback( (event: WebViewPostEvent) => { @@ -326,9 +216,7 @@ export default function useTTS({ [readingTime], ); - const stopNativePlayback = useCallback(() => { - stopNativeEngines(); - }, []); + const stopNativePlayback = playback.stopAll; const scheduleAutoStart = useCallback(() => { clearAutoStartTimer(); @@ -360,7 +248,7 @@ export default function useTTS({ () => ({ handleLoadEnd, handlePause, - handleQueue, + handleQueue: playback.handleQueue, handleSpeak, handleState, handleStop, @@ -370,7 +258,7 @@ export default function useTTS({ [ handleLoadEnd, handlePause, - handleQueue, + playback.handleQueue, handleSpeak, handleState, handleStop, diff --git a/src/screens/reader/components/Hooks/useTTSPlayback.ts b/src/screens/reader/components/Hooks/useTTSPlayback.ts new file mode 100644 index 0000000000..675f73111e --- /dev/null +++ b/src/screens/reader/components/Hooks/useTTSPlayback.ts @@ -0,0 +1,97 @@ +import type { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import TTSPlaybackManager, { TTSPlaybackCallbacks } from './TTSPlaybackManager'; +import type { WebViewPostEvent } from './webViewEvents'; + +type UseTTSPlaybackOptions = TTSPlaybackCallbacks & { + readerSettingsRef: React.RefObject; +}; + +export default function useTTSPlayback({ + readerSettingsRef, + ...callbacks +}: UseTTSPlaybackOptions) { + const sessionIdRef = useRef(Symbol('tts-playback-session')); + const callbacksRef = useRef(callbacks); + const queueRef = useRef([]); + const queueIndexRef = useRef(0); + callbacksRef.current = callbacks; + + const getCallbacks = useCallback( + (onWillPlay?: () => void): TTSPlaybackCallbacks => ({ + onWillPlay, + onStart: () => callbacksRef.current.onStart?.(), + onDone: () => callbacksRef.current.onDone?.(), + onError: (error, message) => + callbacksRef.current.onError?.(error, message), + onInterrupted: () => callbacksRef.current.onInterrupted?.(), + }), + [], + ); + + const playText = useCallback( + (text: string, onWillPlay?: () => void) => { + const normalizedText = text.trim(); + if (!normalizedText) return false; + return TTSPlaybackManager.speak( + sessionIdRef.current, + normalizedText, + readerSettingsRef.current.tts || {}, + getCallbacks(onWillPlay), + ); + }, + [getCallbacks, readerSettingsRef], + ); + + const handleQueue = useCallback((event: WebViewPostEvent) => { + const payload = event.data as + | { queue?: unknown; startIndex?: unknown } + | undefined; + queueRef.current = Array.isArray(payload?.queue) + ? payload.queue.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) + : []; + queueIndexRef.current = + typeof payload?.startIndex === 'number' ? payload.startIndex : 0; + }, []); + + const handleSpeak = useCallback( + (event: WebViewPostEvent, onWillPlay?: () => void) => { + if (typeof event.data !== 'string') return false; + if (typeof event.index === 'number') { + queueIndexRef.current = event.index; + } + const started = playText(event.data, onWillPlay); + if (started && readerSettingsRef.current.tts?.engine === 'tiktok') { + TTSPlaybackManager.updateQueue( + sessionIdRef.current, + queueRef.current.slice(queueIndexRef.current + 1), + readerSettingsRef.current.tts, + ); + } + return started; + }, + [playText, readerSettingsRef], + ); + + const pause = useCallback( + () => TTSPlaybackManager.pause(sessionIdRef.current), + [], + ); + const stop = useCallback(() => { + TTSPlaybackManager.stop(sessionIdRef.current); + queueRef.current = []; + queueIndexRef.current = 0; + }, []); + const stopAll = useCallback(() => TTSPlaybackManager.stopAll(), []); + + useEffect(() => stop, [stop]); + + return useMemo( + () => ({ handleQueue, handleSpeak, pause, playText, stop, stopAll }), + [handleQueue, handleSpeak, pause, playText, stop, stopAll], + ); +} diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderBottomSheet.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderBottomSheet.tsx deleted file mode 100644 index 115d45a976..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderBottomSheet.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import BottomSheet from '@components/BottomSheet/BottomSheet'; -import { BottomSheetFlashList, BottomSheetView } from '@gorhom/bottom-sheet'; -import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; -import { useChapterGeneralSettings, useTheme } from '@hooks/persisted'; -import { getString } from '@strings/translations'; -import { StringMap } from '@strings/types'; -import Color from 'color'; -import React, { - RefObject, - Suspense, - useCallback, - useMemo, - useState, -} from 'react'; -import { - StyleProp, - StyleSheet, - Text, - TextStyle, - useWindowDimensions, - View, -} from 'react-native'; -import { overlay } from 'react-native-paper'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { SceneMap, TabBar, TabView } from 'react-native-tab-view'; - -import ReaderFontPicker from './ReaderFontPicker'; -import ReaderSheetPreferenceItem from './ReaderSheetPreferenceItem'; -import ReaderTextAlignSelector from './ReaderTextAlignSelector'; -import ReaderThemeSelector from './ReaderThemeSelector'; -import ReaderValueChange from './ReaderValueChange'; -import TextSizeSlider from './TextSizeSlider'; -import TranslateTab from './TranslateTab'; -import TTSTab from './TTSTab'; - -type TabViewLabelProps = { - route: { - key: string; - title: string; - }; - labelText?: string; - focused: boolean; - color: string; - allowFontScaling?: boolean; - style?: StyleProp; -}; - -const ReaderTab: React.FC = React.memo(() => ( - }> - - - - - - - - - -)); - -const GeneralTab: React.FC = React.memo(() => { - const theme = useTheme(); - const { setChapterGeneralSettings, ...settings } = - useChapterGeneralSettings(); - - const toggleSetting = useCallback( - (key: keyof typeof settings) => - setChapterGeneralSettings({ [key]: !settings[key] }), - [setChapterGeneralSettings, settings], - ); - - const preferences = useMemo( - () => [ - { key: 'fullScreenMode', label: 'fullscreen' }, - { key: 'autoScroll', label: 'autoscroll' }, - { key: 'verticalSeekbar', label: 'verticalSeekbar' }, - { key: 'showBatteryAndTime', label: 'showBatteryAndTime' }, - { key: 'showScrollPercentage', label: 'showProgressPercentage' }, - { key: 'swipeGestures', label: 'swipeGestures' }, - { key: 'pageReader', label: 'pageReader' }, - { key: 'removeExtraParagraphSpacing', label: 'removeExtraSpacing' }, - { key: 'useVolumeButtons', label: 'volumeButtonsScroll' }, - { key: 'bionicReading', label: 'bionicReading' }, - { key: 'tapToScroll', label: 'tapToScroll' }, - { key: 'keepScreenOn', label: 'keepScreenOn' }, - ], - [], - ); - - const renderItem = useCallback( - ({ item }: { item: { key: string; label: string } }) => ( - toggleSetting(item.key as keyof typeof settings)} // @ts-ignore - value={settings[item.key]} - theme={theme} - /> - ), - [settings, theme, toggleSetting], - ); - - return ( - item.key} - renderItem={renderItem} - estimatedItemSize={60} - /> - ); -}); - -interface ReaderBottomSheetV2Props { - bottomSheetRef: RefObject; -} - -const routes = [ - { key: 'readerTab', title: getString('readerSettings.title') }, - { key: 'generalTab', title: getString('generalSettings') }, - { key: 'ttsTab', title: 'TTS' }, - { key: 'translateTab', title: 'Translate' }, -]; - -const ReaderBottomSheetV2: React.FC = ({ - bottomSheetRef, -}) => { - const theme = useTheme(); - const { bottom, left, right } = useSafeAreaInsets(); - const layout = useWindowDimensions(); - - const tabHeaderColor = overlay(2, theme.surface); - const backgroundColor = tabHeaderColor; - - const renderScene = useMemo( - () => - SceneMap({ - readerTab: ReaderTab, - generalTab: GeneralTab, - ttsTab: TTSTab, - translateTab: TranslateTab, - }), - [], - ); - - const [index, setIndex] = useState(0); - - const renderTabBar = useCallback( - (props: any) => ( - - ), - [theme, tabHeaderColor], - ); - - const renderLabel = useCallback(({ route, color }: TabViewLabelProps) => { - return {route.title}; - }, []); - - return ( - - - - - - ); -}; - -export default React.memo(ReaderBottomSheetV2); - -const styles = StyleSheet.create({ - container: { - borderRadius: 8, - }, - readerTab: { - paddingVertical: 8, - }, - tabBar: { - borderBottomWidth: 0.5, - elevation: 0, - }, - tabView: { - borderTopLeftRadius: 8, - borderTopRightRadius: 8, - height: 600, - }, - flex: { flex: 1 }, -}); diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx deleted file mode 100644 index ecbb09ef66..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { SelectableChip } from '@components'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { getString } from '@strings/translations'; -import { Font, readerFonts } from '@utils/constants/readerConstants'; -import React, { useCallback } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { FlatList } from 'react-native-gesture-handler'; - -interface FontChipProps { - item: Font; -} - -const ReaderFontPicker = () => { - const theme = useTheme(); - const { fontFamily, setChapterReaderSettings } = useChapterReaderSettings(); - - const isSelected = useCallback( - (item: Font) => item.fontFamily === fontFamily, - [fontFamily], - ); - - const FontChipItem = useCallback( - ({ item }: FontChipProps) => { - return ( - - setChapterReaderSettings({ fontFamily: item.fontFamily }) - } - customFontFamily={item.fontFamily} - /> - ); - }, - [isSelected, setChapterReaderSettings, theme], - ); - - return ( - - - {getString('readerScreen.bottomSheet.fontStyle')} - - 'font' + index} - horizontal={true} - showsHorizontalScrollIndicator={false} - /> - - ); -}; - -export default React.memo(ReaderFontPicker); -const styles = StyleSheet.create({ - row: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - paddingHorizontal: 16, - paddingVertical: 6, - }, - title: { - marginEnd: 16, - }, -}); diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderSheetPreferenceItem.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderSheetPreferenceItem.tsx deleted file mode 100644 index b7f291b7eb..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderSheetPreferenceItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Switch from '@components/Switch/Switch'; -import React, { Suspense } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { ThemeColors } from '../../../../theme/types'; - -interface ReaderSheetPreferenceItemProps { - label: string; - value: boolean; - onPress: () => void; - theme: ThemeColors; -} - -const ReaderSheetPreferenceItem: React.FC = ({ - label, - value, - onPress, - theme, -}) => { - return ( - - - {label} - - }> - - - - ); -}; - -export default ReaderSheetPreferenceItem; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - }, - label: { - flex: 1, - paddingRight: 16, - }, - fallback: { - width: 52, - height: 32, - borderRadius: 16, - }, -}); diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderThemeSelector.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderThemeSelector.tsx deleted file mode 100644 index 1098016357..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderThemeSelector.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { ToggleColorButton } from '@components/Common/ToggleButton'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { ReaderTheme } from '@hooks/persisted/useSettings'; -import { getString } from '@strings/translations'; -import { presetReaderThemes } from '@utils/constants/readerConstants'; -import React from 'react'; -import { StyleSheet, Text, TextStyle, View } from 'react-native'; -import { FlatList } from 'react-native-gesture-handler'; - -interface ReaderThemeSelectorProps { - label?: string; - labelStyle?: TextStyle | TextStyle[]; -} - -const ReaderThemeSelector: React.FC = ({ - label, - labelStyle, -}) => { - const theme = useTheme(); - - const { - theme: backgroundColor, - textColor, - customThemes, - setChapterReaderSettings, - } = useChapterReaderSettings(); - - return ( - - - {label || getString('readerScreen.bottomSheet.color')} - - ( - - setChapterReaderSettings({ - theme: item.backgroundColor, - textColor: item.textColor, - }) - } - /> - )} - keyExtractor={(item, index) => item.textColor + '_' + index} - horizontal={true} - showsHorizontalScrollIndicator={false} - /> - - ); -}; - -export default ReaderThemeSelector; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - marginVertical: 8, - paddingHorizontal: 16, - }, - scrollView: { - flexGrow: 1, - justifyContent: 'flex-end', - }, - title: { - marginRight: 16, - }, -}); diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx deleted file mode 100644 index d3b4b9b925..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/ReaderValueChange.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { StyleSheet, Text, TextStyle, View } from 'react-native'; -import React from 'react'; - -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { IconButtonV2 } from '@components'; -import { ChapterReaderSettings } from '@hooks/persisted/useSettings'; - -type ValueKey = Exclude< - { - [K in keyof T]: T[K] extends number ? K : never; - }[keyof T], - undefined ->; - -interface ReaderValueChangeProps { - labelStyle?: TextStyle | TextStyle[]; - valueChange?: number; - label: string; - valueKey: ValueKey; - decimals?: number; - min?: number; - max?: number; - unit?: string; -} - -const ReaderValueChange: React.FC = ({ - labelStyle, - label, - valueChange = 0.1, - valueKey, - decimals = 1, - min = 1.3, - max = 2, - unit = '%', -}) => { - const theme = useTheme(); - const { setChapterReaderSettings, ...settings } = useChapterReaderSettings(); - - return ( - - - {label} - - - - setChapterReaderSettings({ - [valueKey]: Math.max(min, settings[valueKey] - valueChange), - }) - } - theme={theme} - /> - - {`${((settings[valueKey] * 10) / 10).toFixed(decimals)}${unit}`} - - = max} - onPress={() => - setChapterReaderSettings({ - [valueKey]: Math.min(max, settings[valueKey] + valueChange), - }) - } - theme={theme} - /> - - - ); -}; - -export default ReaderValueChange; - -const styles = StyleSheet.create({ - buttonContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - marginVertical: 6, - paddingHorizontal: 16, - }, - value: { - paddingHorizontal: 4, - textAlign: 'center', - width: 60, - }, -}); diff --git a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx b/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx deleted file mode 100644 index 2e9d0a119a..0000000000 --- a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx +++ /dev/null @@ -1,732 +0,0 @@ -import { Button, List } from '@components'; -import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; -import { - useChapterGeneralSettings, - useChapterReaderSettings, - useTheme, -} from '@hooks/persisted'; -import Slider from '@react-native-community/slider'; -import { getString } from '@strings/translations'; -import { getLocales } from 'expo-localization'; -import { getAvailableVoicesAsync, Voice } from 'expo-speech'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { Chip, IconButton, Modal, Portal } from 'react-native-paper'; - -import ReaderSheetPreferenceItem from './ReaderSheetPreferenceItem'; - -const TIKTOK_VOICES = [ - { id: 'BV074_streaming', name: 'Cô gái hoạt ngôn', lang: 'vi' }, - { id: 'BV421_vivn_streaming', name: 'Cô gái ngọt ngào', lang: 'vi' }, - { id: 'BV075_streaming', name: 'Thanh niên tự tin', lang: 'vi' }, - { id: 'vi_female_huong', name: 'Giọng nữ phổ thông', lang: 'vi' }, - { id: 'en_male_jomboy', name: 'Game On', lang: 'en' }, - { id: 'en_us_002', name: 'Jessie', lang: 'en' }, - { id: 'es_mx_002', name: 'Warm', lang: 'en' }, - { id: 'en_male_funny', name: 'Wacky', lang: 'en' }, - { id: 'en_us_ghostface', name: 'Scream', lang: 'en' }, - { id: 'en_female_samc', name: 'Empathetic', lang: 'en' }, - { id: 'en_male_cody', name: 'Serious', lang: 'en' }, - { id: 'en_female_makeup', name: 'Beauty Guru', lang: 'en' }, - { id: 'en_female_richgirl', name: 'Bestie', lang: 'en' }, - { id: 'en_male_grinch', name: 'Trickster', lang: 'en' }, - { id: 'en_us_006', name: 'Joey', lang: 'en' }, - { id: 'en_male_narration', name: 'Story Teller', lang: 'en' }, - { id: 'en_male_deadpool', name: 'Mr. GoodGuy', lang: 'en' }, - { id: 'en_uk_001', name: 'Narrator', lang: 'en' }, - { id: 'en_uk_003', name: 'Male English UK', lang: 'en' }, - { id: 'en_au_001', name: 'Metro', lang: 'en' }, - { id: 'en_male_jarvis', name: 'Alfred', lang: 'en' }, - { id: 'en_male_ashmagic', name: 'ashmagic', lang: 'en' }, - { id: 'en_male_olantekkers', name: 'olantekkers', lang: 'en' }, - { id: 'en_male_ukneighbor', name: 'Lord Cringe', lang: 'en' }, - { id: 'en_male_ukbutler', name: 'Mr. Meticulous', lang: 'en' }, - { id: 'en_female_shenna', name: 'Debutante', lang: 'en' }, - { id: 'en_female_pansino', name: 'Varsity', lang: 'en' }, - { id: 'en_male_trevor', name: 'Marty', lang: 'en' }, - { id: 'en_female_f08_twinkle', name: 'Pop Lullaby', lang: 'en' }, - { id: 'en_male_m03_classical', name: 'Classic Electric', lang: 'en' }, - { id: 'en_female_betty', name: 'Bae', lang: 'en' }, - { id: 'en_male_cupid', name: 'Cupid', lang: 'en' }, - { id: 'en_female_grandma', name: 'Granny', lang: 'en' }, - { id: 'en_male_m2_xhxs_m03_christmas', name: 'Cozy', lang: 'en' }, - { id: 'en_male_santa_narration', name: 'Author', lang: 'en' }, - { id: 'en_male_sing_deep_jingle', name: 'Caroler', lang: 'en' }, - { id: 'en_male_santa_effect', name: 'Santa', lang: 'en' }, - { id: 'en_female_ht_f08_newyear', name: 'NYE 2023', lang: 'en' }, - { id: 'en_male_wizard', name: 'Magician', lang: 'en' }, - { id: 'en_female_ht_f08_halloween', name: 'Opera', lang: 'en' }, - { id: 'en_female_ht_f08_glorious', name: 'Euphoric', lang: 'en' }, - { id: 'en_male_sing_funny_it_goes_up', name: 'Hypetrain', lang: 'en' }, - { id: 'en_female_ht_f08_wonderful_world', name: 'Melodrama', lang: 'en' }, - { id: 'en_male_m2_xhxs_m03_silly', name: 'Quirky Time', lang: 'en' }, - { id: 'en_female_emotional', name: 'Peaceful', lang: 'en' }, - { id: 'en_male_m03_sunshine_soon', name: 'Toon Beat', lang: 'en' }, - { id: 'en_female_f08_warmy_breeze', name: 'Open Mic', lang: 'en' }, - { id: 'en_male_m03_lobby', name: 'Jingle', lang: 'en' }, - { id: 'en_male_sing_funny_thanksgiving', name: 'Thanksgiving', lang: 'en' }, - { id: 'en_female_f08_salut_damour', name: 'Cottagecore', lang: 'en' }, - { id: 'en_us_007', name: 'Professor', lang: 'en' }, - { id: 'en_us_009', name: 'Scientist', lang: 'en' }, - { id: 'en_us_010', name: 'Confidence', lang: 'en' }, - { id: 'en_au_002', name: 'Smooth', lang: 'en' }, - { id: 'en_us_ghostface', name: 'Ghost Face', lang: 'en' }, - { id: 'en_us_chewbacca', name: 'Chewbacca', lang: 'en' }, - { id: 'en_us_c3po', name: 'C3PO', lang: 'en' }, - { id: 'en_us_stitch', name: 'Stitch', lang: 'en' }, - { id: 'en_us_stormtrooper', name: 'Stormtrooper', lang: 'en' }, - { id: 'en_us_rocket', name: 'Rocket', lang: 'en' }, - { id: 'en_female_madam_leota', name: 'Madame Leota', lang: 'en' }, - { id: 'en_male_ghosthost', name: 'Ghost Host', lang: 'en' }, - { id: 'en_male_pirate', name: 'Pirate', lang: 'en' }, - { id: 'fr_001', name: 'French - Male 1', lang: 'fr' }, - { id: 'fr_002', name: 'French - Male 2', lang: 'fr' }, - { id: 'es_002', name: 'Spanish (Spain) - Male', lang: 'es' }, - { id: 'es_mx_002', name: 'Spanish MX - Male', lang: 'es' }, - { id: 'br_001', name: 'Portuguese BR - Female 1', lang: 'pt' }, - { id: 'br_003', name: 'Portuguese BR - Female 2', lang: 'pt' }, - { id: 'br_004', name: 'Portuguese BR - Female 3', lang: 'pt' }, - { id: 'br_005', name: 'Portuguese BR - Male', lang: 'pt' }, - { id: 'bp_female_ivete', name: 'Ivete Sangalo', lang: 'pt' }, - { id: 'bp_female_ludmilla', name: 'Ludmilla', lang: 'pt' }, - { id: 'pt_female_lhays', name: 'Lhays Macedo', lang: 'pt' }, - { id: 'pt_female_laizza', name: 'Laizza', lang: 'pt' }, - { id: 'pt_male_bueno', name: 'Galvão Bueno', lang: 'pt' }, - { id: 'de_001', name: 'German - Female', lang: 'de' }, - { id: 'de_002', name: 'German - Male', lang: 'de' }, - { id: 'id_001', name: 'Indonesian - Female', lang: 'id' }, - { id: 'jp_001', name: 'Japanese - Female 1', lang: 'ja' }, - { id: 'jp_003', name: 'Japanese - Female 2', lang: 'ja' }, - { id: 'jp_005', name: 'Japanese - Female 3', lang: 'ja' }, - { id: 'jp_006', name: 'Japanese - Male', lang: 'ja' }, - { id: 'jp_female_fujicochan', name: 'りーさ', lang: 'ja' }, - { id: 'jp_female_hasegawariona', name: '世羅鈴', lang: 'ja' }, - { id: 'jp_male_keiichinakano', name: 'Morio’s Kitchen', lang: 'ja' }, - { id: 'jp_female_oomaeaika', name: '夏絵ココ', lang: 'ja' }, - { id: 'jp_male_yujinchigusa', name: '低音ボイス', lang: 'ja' }, - { id: 'jp_female_shirou', name: '四郎', lang: 'ja' }, - { id: 'jp_male_tamawakazuki', name: '玉川寿紀', lang: 'ja' }, - { id: 'jp_female_kaorishoji', name: '庄司果織', lang: 'ja' }, - { id: 'jp_female_yagishaki', name: '八木沙季', lang: 'ja' }, - { id: 'jp_male_hikakin', name: 'ヒカキン', lang: 'ja' }, - { id: 'jp_female_rei', name: '丸山礼', lang: 'ja' }, - { id: 'jp_male_shuichiro', name: '修一朗', lang: 'ja' }, - { id: 'jp_male_matsudake', name: 'マツダ家の日常', lang: 'ja' }, - { id: 'jp_female_machikoriiita', name: 'まちこりーた', lang: 'ja' }, - { id: 'jp_male_matsuo', name: 'モジャオ', lang: 'ja' }, - { id: 'jp_male_osada', name: 'モリスケ', lang: 'ja' }, - { id: 'kr_002', name: 'Korean - Male 1', lang: 'ko' }, - { id: 'kr_003', name: 'Korean - Female', lang: 'ko' }, - { id: 'kr_004', name: 'Korean - Male 2', lang: 'ko' }, -]; - -interface VoicePickerModalProps { - visible: boolean; - onDismiss: () => void; - voices: Voice[]; - onSelect: (voice: Voice) => void; - currentVoice?: Voice; -} - -const VoicePickerModal: React.FC< - VoicePickerModalProps & { isTikTok: boolean } -> = ({ visible, onDismiss, voices, onSelect, currentVoice, isTikTok }) => { - const theme = useTheme(); - const [selectedLanguages, setSelectedLanguages] = useState([]); - // Get system language safely using getLocales() - const systemLocale = getLocales()[0]?.languageCode || 'en'; - - const availableVoices = useMemo(() => { - if (isTikTok) { - return TIKTOK_VOICES.map(v => ({ - identifier: v.id, - name: v.name, - language: v.lang, - quality: 'Default', - })) as Voice[]; - } - return voices; - }, [isTikTok, voices]); - - // Get unique languages from voices - const availableLanguages = useMemo(() => { - const languages = new Set(); - availableVoices.forEach(voice => { - if (voice.language) { - const lang = voice.language.split('-')[0]; - languages.add(lang); - } - }); - return Array.from(languages).sort((a, b) => { - // System language first - if (a === systemLocale) return -1; - if (b === systemLocale) return 1; - return a.localeCompare(b); - }); - }, [availableVoices, systemLocale]); - - // Filter voices by selected languages - const filteredVoices = useMemo(() => { - if (selectedLanguages.length === 0) { - // Show system language voices by default - return availableVoices.filter(voice => { - if (voice.name === 'System') return true; - const lang = voice.language?.split('-')[0]; - return lang === systemLocale; - }); - } - - return availableVoices.filter(voice => { - if (voice.name === 'System') return true; - const lang = voice.language?.split('-')[0]; - return lang && selectedLanguages.includes(lang); - }); - }, [availableVoices, selectedLanguages, systemLocale]); - - const toggleLanguage = (lang: string) => { - setSelectedLanguages(prev => { - if (prev.includes(lang)) { - return prev.filter(l => l !== lang); - } else { - return [...prev, lang]; - } - }); - }; - - useEffect(() => { - // Reset to system language when modal opens - if (visible) { - setSelectedLanguages([]); - } - }, [visible]); - - return ( - - - - Select Voice - - - {/* Language Filter */} - - - Filter by language: - - - {availableLanguages.map(lang => { - const isSelected = selectedLanguages.includes(lang); - const isSystemLang = lang === systemLocale; - const showingSystemOnly = selectedLanguages.length === 0; - const isActive = - isSelected || (showingSystemOnly && isSystemLang); - - return ( - toggleLanguage(lang)} - style={[ - styles.languageChip, - isActive && { backgroundColor: theme.primary }, - ]} - textStyle={[ - styles.languageChipText, - { color: isActive ? theme.onPrimary : theme.onSurface }, - ]} - > - {lang.toUpperCase()} - {isSystemLang && ' (System)'} - - ); - })} - - - - {/* Voice List */} - - {filteredVoices.length === 0 ? ( - - No voices available for selected languages - - ) : ( - filteredVoices.map((voice: Voice, index: number) => ( - { - onSelect(voice); - onDismiss(); - }} - > - - - {voice.name} - - {voice.language && ( - - {voice.language} - - )} - - {currentVoice?.identifier === voice.identifier && ( - - ✓ - - )} - - )) - )} - - -

'; + chapterElement.innerHTML = customDOM; + reader.generalSettings.val = { + ...reader.generalSettings.val, + showBatteryAndTime: true, + }; + derives.forEach(derive => derive()); + + expect(chapterElement.innerHTML).toBe(customDOM); + }); +}); From 963ed99c8a3084c60a1996e0317c896145f3b3f0 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:59:16 +0700 Subject: [PATCH 13/18] test: isolate reader lifecycle mocks --- android/app/src/main/assets/js/theme.js | 5 ++++- .../reader/components/__tests__/WebViewReader.test.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/assets/js/theme.js b/android/app/src/main/assets/js/theme.js index 4219fa1ae8..6b493c1109 100644 --- a/android/app/src/main/assets/js/theme.js +++ b/android/app/src/main/assets/js/theme.js @@ -81,7 +81,10 @@ : _ }`, ) //if p found, delete all double br near p - .replace(/
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, ''); + .replace( + /
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, + '', + ); } if (bionicReading || removeExtraParagraphSpacing) { diff --git a/src/screens/reader/components/__tests__/WebViewReader.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.test.tsx index 1b47c6c5d5..acbc9deba3 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.test.tsx @@ -250,6 +250,7 @@ const rerenderReader = (view: ReturnType) => describe('WebViewReader document lifecycle', () => { beforeEach(() => { + jest.clearAllMocks(); mockReaderSettings = createReaderSettings(); mockGeneralSettings = createGeneralSettings(); mockChapterContext = { From 33a19f63defb30bca6cceb9749210d0de1a86a03 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:06:15 +0700 Subject: [PATCH 14/18] fix: run custom scripts in epub chapters --- .../src/__tests__/main.test.ts | 77 +++++++++++++++++++ src/modules/epub-constructor/src/main.ts | 2 +- .../src/methods/createChapter.ts | 7 +- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/modules/epub-constructor/src/__tests__/main.test.ts diff --git a/src/modules/epub-constructor/src/__tests__/main.test.ts b/src/modules/epub-constructor/src/__tests__/main.test.ts new file mode 100644 index 0000000000..0d7edffedf --- /dev/null +++ b/src/modules/epub-constructor/src/__tests__/main.test.ts @@ -0,0 +1,77 @@ +import EpubFile from '../main'; + +jest.mock('react-native-quick-crypto', () => ({ + randomUUID: jest.fn(() => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'), +})); + +describe('EpubFile.constructEpub', () => { + it('wires custom JavaScript only into scripted EPUB chapter artifacts', async () => { + const customJavaScript = 'document.body.dataset.ready = "true";'; + const scriptedFiles = await new EpubFile({ + title: 'Scripted Book', + bookId: 'scripted-book-id', + js: customJavaScript, + chapters: [ + { + title: 'Chapter One', + htmlBody: '

Chapter body

', + }, + ], + }).constructEpub(); + + expect(scriptedFiles.find(file => file.path === 'EPUB/script.js')?.content) + .toBe(`function fnEpub(){${customJavaScript}}`); + + const scriptedChapters = scriptedFiles.filter(file => + /^EPUB\/content\/.*\.xhtml$/.test(file.path), + ); + expect(scriptedChapters).toHaveLength(1); + scriptedChapters.forEach(chapter => { + const chapterBodyIndex = chapter.content.indexOf( + '

Chapter body

', + ); + const scriptReferenceIndex = chapter.content.indexOf( + '', + ); + const invocationIndex = chapter.content.indexOf( + '', + ); + const bodyCloseIndex = chapter.content.indexOf(''); + + expect(chapterBodyIndex).toBeGreaterThan(-1); + expect(scriptReferenceIndex).toBeGreaterThan(chapterBodyIndex); + expect(invocationIndex).toBeGreaterThan(scriptReferenceIndex); + expect(bodyCloseIndex).toBeGreaterThan(invocationIndex); + }); + + const packageDocument = scriptedFiles.find( + file => file.path === 'EPUB/package.opf', + )?.content; + expect(packageDocument).toContain( + '', + ); + expect(packageDocument).toContain('properties="scripted"'); + + const plainFiles = await new EpubFile({ + title: 'Plain Book', + bookId: 'plain-book-id', + chapters: [ + { + title: 'Chapter One', + htmlBody: '

Chapter body

', + }, + ], + }).constructEpub(); + + expect(plainFiles.some(file => file.path === 'EPUB/script.js')).toBe(false); + + const plainChapters = plainFiles.filter(file => + /^EPUB\/content\/.*\.xhtml$/.test(file.path), + ); + expect(plainChapters).toHaveLength(1); + plainChapters.forEach(chapter => { + expect(chapter.content).not.toContain('`); ol.push( `
  • ${escapeXml( diff --git a/src/modules/epub-constructor/src/methods/createChapter.ts b/src/modules/epub-constructor/src/methods/createChapter.ts index beef05695a..adc2bb3f1f 100644 --- a/src/modules/epub-constructor/src/methods/createChapter.ts +++ b/src/modules/epub-constructor/src/methods/createChapter.ts @@ -7,8 +7,11 @@ import { escapeXml } from './xmlEscape'; * Creates a valid EPUB XHTML chapter document. Chapter body HTML is filtered * and repaired by the htmlparser2-backed EPUB 3.3 purifier before wrapping. */ -export function createChapter(chapter: EpubChapter) { +export function createChapter(chapter: EpubChapter, includeScript = false) { const xhtmlBody = htmlToXhtml(chapter.htmlBody); + const script = includeScript + ? '\n \n ' + : ''; return createFile( `EPUB/${chapter.fileName}`, @@ -20,7 +23,7 @@ export function createChapter(chapter: EpubChapter) { ${escapeXml(chapter.title)} -${xhtmlBody} +${xhtmlBody}${script} `, ); From de7a3f80a328b586b0cbe1e9fa3d6fc1b82498a3 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:13:44 +0700 Subject: [PATCH 15/18] Update ColorPickerModal.tsx --- src/components/ColorPickerModal/ColorPickerModal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index 3769c63746..546fa62fb4 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -75,6 +75,8 @@ const ColorPickerModal: React.FC = ({ = ({ Date: Sun, 19 Jul 2026 18:23:05 +0700 Subject: [PATCH 16/18] fix: keep reader reloads progress-stable --- .../reader/components/WebViewReader.tsx | 8 +++-- .../__tests__/WebViewReader.test.tsx | 2 ++ .../reader/hooks/__tests__/useChapter.test.ts | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index e93c5dd173..7404258502 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -132,8 +132,12 @@ const WebViewReader: React.FC = ({ processedHtml, novel, chapter: { ...chapter, progress: undefined }, - nextChapter, - prevChapter, + nextChapter: nextChapter + ? { ...nextChapter, progress: undefined } + : undefined, + prevChapter: prevChapter + ? { ...prevChapter, progress: undefined } + : undefined, customCSS, customJS, pluginUseCustomCSS: readerSettings.pluginUseCustomCSS, diff --git a/src/screens/reader/components/__tests__/WebViewReader.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.test.tsx index acbc9deba3..12296c5f63 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.test.tsx @@ -381,6 +381,8 @@ describe('WebViewReader document lifecycle', () => { mockChapterContext = { ...mockChapterContext, chapter: { ...chapter, progress: 88 }, + nextChapter: { ...nextChapter, progress: 64 }, + prevChapter: { ...prevChapter, progress: 42 }, }; rerenderReader(view); diff --git a/src/screens/reader/hooks/__tests__/useChapter.test.ts b/src/screens/reader/hooks/__tests__/useChapter.test.ts index 3437df313e..a7bfd264f7 100644 --- a/src/screens/reader/hooks/__tests__/useChapter.test.ts +++ b/src/screens/reader/hooks/__tests__/useChapter.test.ts @@ -427,6 +427,35 @@ describe('useChapter', () => { expect(TTSPlaybackManager.stopAll).toHaveBeenCalledTimes(1); }); + it('remounts a downloaded chapter when its plugin is missing without reading or clearing content', async () => { + const downloadedChapter = { ...initialChapter, isDownloaded: true }; + const store = createStore({ + [downloadedChapter.id]: 'downloaded chapter body', + }); + mockUseNovelActions.mockReturnValue(store.state); + mockGetDbChapter.mockResolvedValue(downloadedChapter); + + const { result } = renderHook(() => + useChapter({ current: null }, downloadedChapter, novel, false), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.canReloadFromSource).toBe(true); + + act(() => { + result.current.reloadFromSource(); + }); + + expect(result.current.readerVersion).toBe(1); + expect(mockFetchChapter).not.toHaveBeenCalled(); + expect(NativeFile.readFile).not.toHaveBeenCalled(); + expect(store.chapterTextCache.remove).not.toHaveBeenCalled(); + expect(store.chapterTextCache.read(downloadedChapter.id)).toBe( + 'downloaded chapter body', + ); + expect(TTSPlaybackManager.stopAll).toHaveBeenCalledTimes(1); + }); + it('does not reload or remount when the plugin is missing and the chapter is not downloaded', async () => { const store = createStore({ [initialChapter.id]: 'cached chapter body' }); mockUseNovelActions.mockReturnValue(store.state); From 3a95121aa235afaff257660acc2e9f7c9e4eca15 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:27:08 +0700 Subject: [PATCH 17/18] style: format color picker modal --- src/components/ColorPickerModal/ColorPickerModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index 546fa62fb4..7c31f0680b 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -86,7 +86,7 @@ const ColorPickerModal: React.FC = ({ Date: Sun, 19 Jul 2026 20:31:44 +0700 Subject: [PATCH 18/18] fix: restore reader theme swiping --- .../src/__tests__/main.test.ts | 5 +-- .../components/ReaderThemeSelector.tsx | 3 +- .../__tests__/ReaderThemeSelector.test.tsx | 36 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/screens/reader/components/ReaderSettings/components/__tests__/ReaderThemeSelector.test.tsx diff --git a/src/modules/epub-constructor/src/__tests__/main.test.ts b/src/modules/epub-constructor/src/__tests__/main.test.ts index 0d7edffedf..531016b3fc 100644 --- a/src/modules/epub-constructor/src/__tests__/main.test.ts +++ b/src/modules/epub-constructor/src/__tests__/main.test.ts @@ -19,8 +19,9 @@ describe('EpubFile.constructEpub', () => { ], }).constructEpub(); - expect(scriptedFiles.find(file => file.path === 'EPUB/script.js')?.content) - .toBe(`function fnEpub(){${customJavaScript}}`); + expect( + scriptedFiles.find(file => file.path === 'EPUB/script.js')?.content, + ).toBe(`function fnEpub(){${customJavaScript}}`); const scriptedChapters = scriptedFiles.filter(file => /^EPUB\/content\/.*\.xhtml$/.test(file.path), diff --git a/src/screens/reader/components/ReaderSettings/components/ReaderThemeSelector.tsx b/src/screens/reader/components/ReaderSettings/components/ReaderThemeSelector.tsx index 78bed79611..489c1c91a2 100644 --- a/src/screens/reader/components/ReaderSettings/components/ReaderThemeSelector.tsx +++ b/src/screens/reader/components/ReaderSettings/components/ReaderThemeSelector.tsx @@ -3,7 +3,8 @@ import { useChapterReaderSettings } from '@hooks/persisted'; import type { ReaderTheme } from '@hooks/persisted/useSettings'; import { presetReaderThemes } from '@utils/constants/readerConstants'; import React from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; const ReaderThemeSelector = () => { const settings = useChapterReaderSettings(); diff --git a/src/screens/reader/components/ReaderSettings/components/__tests__/ReaderThemeSelector.test.tsx b/src/screens/reader/components/ReaderSettings/components/__tests__/ReaderThemeSelector.test.tsx new file mode 100644 index 0000000000..3cbd0ebb7e --- /dev/null +++ b/src/screens/reader/components/ReaderSettings/components/__tests__/ReaderThemeSelector.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { FlatList as GestureHandlerFlatList } from 'react-native-gesture-handler'; + +import ReaderThemeSelector from '../ReaderThemeSelector'; + +const mockSetChapterReaderSettings = jest.fn(); + +jest.mock('@hooks/persisted', () => ({ + useChapterReaderSettings: () => ({ + customThemes: [], + setChapterReaderSettings: mockSetChapterReaderSettings, + textColor: '#111111', + theme: '#eae4d3', + }), +})); + +describe('ReaderThemeSelector', () => { + it('uses a gesture-aware horizontal list for preset selection', () => { + const view = render(); + const list = view.UNSAFE_getByType(GestureHandlerFlatList); + + expect(list.props.horizontal).toBe(true); + + // eslint-disable-next-line testing-library/render-result-naming-convention + const themeButton = list.props.renderItem({ + index: 0, + item: list.props.data[0], + }); + themeButton.props.onPress(); + expect(mockSetChapterReaderSettings).toHaveBeenCalledWith({ + textColor: '#111111', + theme: '#eae4d3', + }); + }); +});