diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 2129cff27d..17fd928e54 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -42,6 +42,12 @@ body:not(.page-reader) .transition-chapter { padding-right: var(--readerSettings-padding); } +#LNReader-chapter p { + margin-block-start: var(--readerSettings-paragraphSpacing); + margin-block-end: var(--readerSettings-paragraphSpacing); + text-indent: var(--readerSettings-paragraphIndent); +} + hr { margin-top: 20px; margin-bottom: 20px; diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 5817e1da6e..28ff277419 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -131,9 +131,10 @@ function defineSafeProp(globalName, getParent, key) { }); } -defineSafeProp('novelName', () => window.reader?.novel, 'name'); -defineSafeProp('chapterName', () => window.reader?.chapter, 'name'); -defineSafeProp('sourceId', () => window.reader?.novel, 'pluginId'); -defineSafeProp('chapterId', () => window.reader?.chapter, 'id'); -defineSafeProp('novelId', () => window.reader?.novel, 'id'); -defineSafeProp('html', () => window.chapterElement, 'innerHTML'); \ No newline at end of file +defineSafeProp('novelName', () => window.reader?.novel, 'name'); +defineSafeProp('chapterName', () => window.reader?.chapter, 'name'); +defineSafeProp('sourceId', () => window.reader?.novel, 'pluginId'); +defineSafeProp('chapterId', () => window.reader?.chapter, 'id'); +defineSafeProp('novelId', () => window.reader?.novel, 'id'); +defineSafeProp('chapterElement', () => window.reader, 'chapterElement'); +defineSafeProp('html', () => window.chapterElement, 'innerHTML'); diff --git a/android/app/src/main/assets/js/theme.js b/android/app/src/main/assets/js/theme.js index 993c8c7fdb..6b493c1109 100644 --- a/android/app/src/main/assets/js/theme.js +++ b/android/app/src/main/assets/js/theme.js @@ -12,6 +12,14 @@ '--readerSettings-padding', settings.padding + 'px', ); + document.documentElement.style.setProperty( + '--readerSettings-paragraphIndent', + (settings.paragraphIndent ?? 0) + 'em', + ); + document.documentElement.style.setProperty( + '--readerSettings-paragraphSpacing', + (settings.paragraphSpacing ?? 1) + 'em', + ); document.documentElement.style.setProperty( '--readerSettings-textSize', settings.textSize + 'px', @@ -50,30 +58,36 @@ // text options (() => { - van.derive(() => { - let html = reader.rawHTML; - if (reader.generalSettings.val.bionicReading) { - html = textVide.textVide(reader.rawHTML); - } + const { bionicReading, removeExtraParagraphSpacing } = + reader.generalSettings.val; + let html = reader.rawHTML; + if (bionicReading) { + html = textVide.textVide(reader.rawHTML); + } - if (reader.generalSettings.val.removeExtraParagraphSpacing) { - html = html - .replace(/(?: \s*|[\u200b]\s*)+(?=<\/?p[> ])/g, '') - .replace(/
\s*
\s*(?:
\s*)+/g, '

') //force max 2 consecutive
, chaining regex - .replace( - /
\s*
[^]+/, - _ => - `${ - /\/p>/.test(_) - ? _.replace( - /
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)
\s*
))\s*/g, - '', - ) - : _ - }`, - ) //if p found, delete all double br near p - .replace(/
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, ''); - } + if (removeExtraParagraphSpacing) { + html = html + .replace(/(?: \s*|[\u200b]\s*)+(?=<\/?p[> ])/g, '') + .replace(/
\s*
\s*(?:
\s*)+/g, '

') //force max 2 consecutive
, chaining regex + .replace( + /
\s*
[^]+/, + _ => + `${ + /\/p>/.test(_) + ? _.replace( + /
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)
\s*
))\s*/g, + '', + ) + : _ + }`, + ) //if p found, delete all double br near p + .replace( + /
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, + '', + ); + } + + if (bionicReading || removeExtraParagraphSpacing) { reader.chapterElement.innerHTML = html; - }); + } })(); diff --git a/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt b/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt index 3dc626fc2f..2152e7453f 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt @@ -153,9 +153,6 @@ class TikTokTTSModule(private val reactContext: ReactApplicationContext) : openWebSockets.add(webSocket) val message = createTikTokMessage(text, voice) webSocket.send(message) - if (waitingForHash == hash) { - sendEvent("TikTokTTS_onStart", null) - } } override fun onMessage(webSocket: WebSocket, bytes: ByteString) { @@ -341,5 +338,6 @@ class TikTokTTSModule(private val reactContext: ReactApplicationContext) : }) audioTrack?.play() + sendEvent("TikTokTTS_onStart", null) } } diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx index 3769c63746..7c31f0680b 100644 --- a/src/components/ColorPickerModal/ColorPickerModal.tsx +++ b/src/components/ColorPickerModal/ColorPickerModal.tsx @@ -75,6 +75,8 @@ const ColorPickerModal: React.FC = ({ = ({ ({ + opacity: disabled ? 0.6 : 1, +}); + +const getToggleButtonContainerStyle = ( selected: boolean, theme: ThemeColors, - disabled?: boolean, ) => ({ - opacity: disabled ? 0.6 : 1, backgroundColor: selected ? Color(theme.primary).alpha(0.12).string() - : 'transparent', + : theme.surface, }); const getToggleColorButtonPressableStyle = (backgroundColor: string) => ({ @@ -42,12 +44,17 @@ export const ToggleButton: React.FC = ({ onPress, disabled, }) => ( - + ({ + KeyboardController: { + dismiss: jest.fn(() => Promise.resolve()), + }, +})); + const mockButtonPresses = new Map unknown>(); type MockButtonProps = { @@ -206,7 +212,7 @@ describe('KeyboardAvoidingModal', () => { }); }); - it('runs reset without dismissing and cancel before one dismiss', () => { + it('runs reset without dismissing and cancel before one dismiss', async () => { const onReset = jest.fn(); const onCancel = jest.fn(); const onDismiss = jest.fn(); @@ -228,6 +234,6 @@ describe('KeyboardAvoidingModal', () => { act(() => pressButton('common.cancel')); expect(onCancel).toHaveBeenCalledTimes(1); - expect(onDismiss).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1)); }); }); diff --git a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx index e038708772..5d7fc72e85 100644 --- a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx +++ b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx @@ -1,6 +1,13 @@ -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; import React from 'react'; import { Keyboard, StyleProp, StyleSheet, Text, ViewStyle } from 'react-native'; +import { KeyboardController } from 'react-native-keyboard-controller'; import KeyboardAwareModal from '../KeyboardAwareModal'; @@ -19,6 +26,9 @@ jest.mock('@hooks/persisted', () => ({ })); jest.mock('react-native-keyboard-controller', () => ({ + KeyboardController: { + dismiss: jest.fn(() => Promise.resolve()), + }, useAnimatedKeyboard: () => ({ height: { value: 0 }, state: { value: 0 }, @@ -50,11 +60,19 @@ jest.mock('react-native-paper', () => { }); describe('KeyboardAwareModal', () => { + const keyboardControllerDismiss = + KeyboardController.dismiss as jest.MockedFunction< + typeof KeyboardController.dismiss + >; + beforeEach(() => { + jest.clearAllMocks(); jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => undefined); + keyboardControllerDismiss.mockReset(); + keyboardControllerDismiss.mockResolvedValue(); }); - it('renders its regions, merges wrapper styles and centralizes dismiss', () => { + it('renders its regions, merges wrapper styles and centralizes dismiss', async () => { const onDismiss = jest.fn(); render( @@ -82,6 +100,85 @@ describe('KeyboardAwareModal', () => { fireEvent.press(screen.getByTestId('dismiss-modal')); + await waitFor(() => { + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + + it('dismisses the controller keyboard before hiding the modal', async () => { + let resolveKeyboardDismiss: (() => void) | undefined; + keyboardControllerDismiss.mockImplementation( + () => + new Promise(resolve => { + resolveKeyboardDismiss = resolve; + }), + ); + const onDismiss = jest.fn(); + + render( + + Modal body + , + ); + + fireEvent.press(screen.getByTestId('dismiss-modal')); + + expect(keyboardControllerDismiss).toHaveBeenCalledWith({ + animated: false, + }); + expect(onDismiss).not.toHaveBeenCalled(); + + await act(async () => resolveKeyboardDismiss?.()); + + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('falls back when the controller keyboard never settles', async () => { + jest.useFakeTimers(); + keyboardControllerDismiss.mockImplementation(() => new Promise(() => {})); + const onDismiss = jest.fn(); + + render( + + Modal body + , + ); + + fireEvent.press(screen.getByTestId('dismiss-modal')); + expect(onDismiss).not.toHaveBeenCalled(); + + await act(async () => jest.runAllTimers()); + + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('ignores repeated dismiss requests while one is in flight', async () => { + let resolveKeyboardDismiss: (() => void) | undefined; + keyboardControllerDismiss.mockImplementation( + () => + new Promise(resolve => { + resolveKeyboardDismiss = resolve; + }), + ); + const onDismiss = jest.fn(); + + render( + + Modal body + , + ); + + fireEvent.press(screen.getByTestId('dismiss-modal')); + fireEvent.press(screen.getByTestId('dismiss-modal')); + + expect(keyboardControllerDismiss).toHaveBeenCalledTimes(1); + + await act(async () => resolveKeyboardDismiss?.()); + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); expect(onDismiss).toHaveBeenCalledTimes(1); }); diff --git a/src/components/Modal/useKeyboardModalDismiss.ts b/src/components/Modal/useKeyboardModalDismiss.ts index d2edf9e2a6..1bf4bfd875 100644 --- a/src/components/Modal/useKeyboardModalDismiss.ts +++ b/src/components/Modal/useKeyboardModalDismiss.ts @@ -1,10 +1,42 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { Keyboard } from 'react-native'; +import { KeyboardController } from 'react-native-keyboard-controller'; + +const KEYBOARD_DISMISS_TIMEOUT_MS = 250; + +const dismissControllerKeyboard = async () => { + let timeout: ReturnType | undefined; + + try { + await Promise.race([ + KeyboardController.dismiss({ animated: false }), + new Promise(resolve => { + timeout = setTimeout(resolve, KEYBOARD_DISMISS_TIMEOUT_MS); + }), + ]); + } catch { + // React Native's keyboard API remains the fallback. + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +const useKeyboardModalDismiss = (onDismiss: () => void) => { + const dismissing = useRef(false); + + return useCallback(async () => { + if (dismissing.current) return; + dismissing.current = true; -const useKeyboardModalDismiss = (onDismiss: () => void) => - useCallback(() => { Keyboard.dismiss(); - onDismiss(); + await dismissControllerKeyboard(); + + try { + onDismiss(); + } finally { + dismissing.current = false; + } }, [onDismiss]); +}; export default useKeyboardModalDismiss; diff --git a/src/hooks/persisted/__mocks__/useSettings.ts b/src/hooks/persisted/__mocks__/useSettings.ts index 6be60639bf..91fcba1490 100644 --- a/src/hooks/persisted/__mocks__/useSettings.ts +++ b/src/hooks/persisted/__mocks__/useSettings.ts @@ -65,10 +65,15 @@ export const initialChapterReaderSettings = { textSize: 16, textAlign: 'left', padding: 16, + paragraphIndent: 0, + paragraphSpacing: 1, fontFamily: '', lineHeight: 1.5, - customCSS: '', - customJS: '', + codeSnippetsCSS: [], + codeSnippetsJS: [], + regexReplacements: [], + pluginUseCustomCSS: true, + pluginUseCustomJS: true, customThemes: [], tts: { rate: 1, diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 61448e0148..1a3502c5ea 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -4,9 +4,14 @@ import { LibraryFilter, LibrarySortOrder, } from '@screens/library/constants/constants'; +import { + type CodeSnippet, + migrateLegacyCustomCode, + type RegexReplacement, +} from '@utils/customCode'; import { getMMKVObject } from '@utils/mmkv/mmkv'; import { Voice } from 'expo-speech'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useMMKVObject } from 'react-native-mmkv'; import type { DohProviderId } from '../../services/network/doh'; @@ -37,6 +42,8 @@ export const CHAPTER_READER_SETTINGS = 'CHAPTER_READER_SETTINGS'; export const TRANSLATE_SETTINGS = 'TRANSLATE_SETTINGS'; export const SECURITY_SETTINGS = 'SECURITY_SETTINGS'; +export type { CodeSnippet, RegexReplacement } from '@utils/customCode'; + export type SwipeAction = 'disabled' | 'bookmark' | 'markAsRead' | 'download'; export interface AppSettings { @@ -153,10 +160,15 @@ export interface ChapterReaderSettings { textSize: number; textAlign: string; padding: number; + paragraphIndent: number; + paragraphSpacing: number; fontFamily: string; lineHeight: number; - customCSS: string; - customJS: string; + codeSnippetsCSS: CodeSnippet[]; + codeSnippetsJS: CodeSnippet[]; + regexReplacements: RegexReplacement[]; + pluginUseCustomCSS: boolean; + pluginUseCustomJS: boolean; customThemes: ReaderTheme[]; tts?: { engine?: 'native' | 'tiktok'; @@ -312,10 +324,15 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { textSize: 16, textAlign: 'left', padding: 16, + paragraphIndent: 0, + paragraphSpacing: 1, fontFamily: '', lineHeight: 1.5, - customCSS: '', - customJS: '', + codeSnippetsCSS: [], + codeSnippetsJS: [], + regexReplacements: [], + pluginUseCustomCSS: true, + pluginUseCustomJS: true, customThemes: [], tts: { engine: 'native', @@ -455,28 +472,52 @@ export const useChapterGeneralSettings = () => { ); }; +type MigrationChapterReaderSettings = ChapterReaderSettings & { + customCSS?: string; + customJS?: string; +}; + export const useChapterReaderSettings = () => { - const [storedSettings = initialChapterReaderSettings, setSettings] = - useMMKVObject(CHAPTER_READER_SETTINGS); + const [storedSettings, setSettings] = + useMMKVObject(CHAPTER_READER_SETTINGS); - // Ensure TTS settings have proper defaults (migration for existing users) - const chapterReaderSettings = useMemo( - () => ({ - ...storedSettings, + const mergedSettings = useMemo( + () => ({ ...initialChapterReaderSettings, ...storedSettings }), + [storedSettings], + ); + const legacyMigration = useMemo( + () => migrateLegacyCustomCode(mergedSettings), + [mergedSettings], + ); + + // Merge new defaults and expose migrated snippets on the first render. + const chapterReaderSettings = useMemo(() => { + const migratedSettings = legacyMigration.settings; + + return { + ...migratedSettings, + textSize: Math.max( + 8, + mergedSettings.textSize ?? initialChapterReaderSettings.textSize, + ), tts: { ...initialChapterReaderSettings.tts, - ...storedSettings.tts, - // Explicitly ensure these defaults if undefined - autoPageAdvance: storedSettings.tts?.autoPageAdvance ?? false, - scrollToTop: storedSettings.tts?.scrollToTop ?? true, - rate: storedSettings.tts?.rate ?? 1, - pitch: storedSettings.tts?.pitch ?? 1, - engine: storedSettings.tts?.engine ?? 'native', - queueSize: storedSettings.tts?.queueSize ?? 3, + ...mergedSettings.tts, + autoPageAdvance: mergedSettings.tts?.autoPageAdvance ?? false, + scrollToTop: mergedSettings.tts?.scrollToTop ?? true, + rate: mergedSettings.tts?.rate ?? 1, + pitch: mergedSettings.tts?.pitch ?? 1, + engine: mergedSettings.tts?.engine ?? 'native', + queueSize: mergedSettings.tts?.queueSize ?? 3, }, - }), - [storedSettings], - ); + } satisfies ChapterReaderSettings; + }, [legacyMigration, mergedSettings]); + + useEffect(() => { + if (legacyMigration.didMigrate) { + setSettings(chapterReaderSettings); + } + }, [chapterReaderSettings, legacyMigration.didMigrate, setSettings]); const setChapterReaderSettings = useCallback( (values: Partial) => 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..531016b3fc --- /dev/null +++ b/src/modules/epub-constructor/src/__tests__/main.test.ts @@ -0,0 +1,78 @@ +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} `, ); diff --git a/src/screens/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx index 07ae681995..8e2a1fe352 100644 --- a/src/screens/novel/components/ExportNovelAsEpubButton.tsx +++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx @@ -3,6 +3,11 @@ import { useBoolean } from '@hooks/index'; import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import { MaterialDesignIconName } from '@type/icon'; +import { + composeCSS, + composeJS, + serializeInlineScriptValue, +} from '@utils/customCode'; import { showToast } from '@utils/showToast'; import React, { useMemo } from 'react'; import { StatusBar, StyleProp, ViewStyle } from 'react-native'; @@ -46,6 +51,14 @@ const ExportNovelAsEpubButton: React.FC = ({ epubUseCustomCSS = false, epubUseCustomJS = false, } = readerSettings; + const customCSS = useMemo( + () => composeCSS(readerSettings.codeSnippetsCSS), + [readerSettings.codeSnippetsCSS], + ); + const customJS = useMemo( + () => composeJS(readerSettings.codeSnippetsJS), + [readerSettings.codeSnippetsJS], + ); const epubStylesheet = useMemo(() => { if (!novel) { @@ -87,13 +100,20 @@ const ExportNovelAsEpubButton: React.FC = ({ : ''; const customStyles = epubUseCustomCSS - ? readerSettings.customCSS + ? customCSS .replace(RegExp(`#sourceId-${novel.pluginId}\\s*\\{`, 'g'), 'body {') .replace(RegExp(`#sourceId-${novel.pluginId}[^.#A-Z]*`, 'gi'), '') : ''; return appThemeStyles + customStyles; - }, [novel, epubUseAppTheme, epubUseCustomCSS, readerSettings, theme.primary]); + }, [ + customCSS, + novel, + epubUseAppTheme, + epubUseCustomCSS, + readerSettings, + theme.primary, + ]); const epubJavaScript = useMemo(() => { if (!novel) { @@ -101,16 +121,16 @@ const ExportNovelAsEpubButton: React.FC = ({ } return ` - let novelName = "${novel.name}"; - let chapterName = ""; - let sourceId = ${novel.pluginId}; - let chapterId = ""; - let novelId = ${novel.id}; - let html = document.querySelector("[data-epub-chapter]").innerHTML; + const novelName = ${serializeInlineScriptValue(novel.name)}; + const chapterName = ""; + const sourceId = ${serializeInlineScriptValue(novel.pluginId)}; + const chapterId = ""; + const novelId = ${novel.id}; + const chapterElement = document.querySelector("[data-epub-chapter]"); - ${readerSettings.customJS} + ${customJS} `; - }, [novel, readerSettings]); + }, [customJS, novel]); const handleExportSubmit = ( destinationUri: string, diff --git a/src/screens/reader/ChapterContext.tsx b/src/screens/reader/ChapterContext.tsx index 53161b7ab9..c8d4c58173 100644 --- a/src/screens/reader/ChapterContext.tsx +++ b/src/screens/reader/ChapterContext.tsx @@ -1,4 +1,5 @@ import { ChapterInfo, NovelInfo } from '@database/types'; +import { getPlugin, LOCAL_PLUGIN_ID } from '@plugins/pluginManager'; import React, { createContext, useContext, useMemo, useRef } from 'react'; import WebView from 'react-native-webview'; @@ -6,6 +7,8 @@ import useChapter from './hooks/useChapter'; type ChapterContextType = ReturnType & { novel: NovelInfo; + plugin: ReturnType; + canUseRemoteSource: boolean; webViewRef: React.RefObject | null>; }; @@ -23,15 +26,26 @@ export function ChapterContextProvider({ initialChapter: ChapterInfo; }) { const webViewRef = useRef(null); - const chapterHookContent = useChapter(webViewRef, initialChapter, novel); + const plugin = getPlugin(novel.pluginId); + const canUseRemoteSource = Boolean( + plugin && novel.pluginId !== LOCAL_PLUGIN_ID, + ); + const chapterHookContent = useChapter( + webViewRef, + initialChapter, + novel, + canUseRemoteSource, + ); const contextValue = useMemo( () => ({ novel, + plugin, + canUseRemoteSource, webViewRef, ...chapterHookContent, }), - [novel, chapterHookContent], + [novel, plugin, canUseRemoteSource, chapterHookContent], ); return ( diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 81805a18ab..12ee50d4f3 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -10,6 +10,7 @@ import { getString } from '@strings/translations'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, StyleSheet, View } from 'react-native'; import { Drawer } from 'react-native-drawer-layout'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ChapterContextProvider, useChapterContext } from './ChapterContext'; @@ -17,8 +18,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'; @@ -76,12 +77,19 @@ export const ChapterContent = ({ webViewRef, hideHeader, refetch, + readerVersion, + canUseRemoteSource, } = useChapterContext(); const readerSheetRef = useRef(null); const theme = useTheme(); const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); const search = useNativeChapterSearch(webViewRef); - const { closeSearch, handleFindResult, visible: searchVisible } = search; + const { + closeSearch, + handleFindResult, + keyboardAvoidanceActive, + visible: searchVisible, + } = search; const [bookmarked, setBookmarked] = useState( chapter.bookmark ?? false, ); @@ -157,6 +165,13 @@ export const ChapterContent = ({ }); }, [chapter.path, navigation, novel.name, novel.pluginId]); + const openTranslateSettings = useCallback(() => { + navigation.navigate('MoreStack', { + screen: 'SettingsStack', + params: { screen: 'TranslateSettings' }, + }); + }, [navigation]); + const handleReaderPress = useCallback(() => { if (searchVisible) { Keyboard.dismiss(); @@ -175,28 +190,37 @@ export const ChapterContent = ({ title: getString('common.retry'), onPress: refetch, }, - { - iconName: 'earth', - title: 'WebView', - onPress: openWebView, - }, + ...(canUseRemoteSource + ? [ + { + iconName: 'earth' as const, + title: 'WebView', + onPress: openWebView, + }, + ] + : []), ]} /> ); } return ( - + {keepScreenOn ? : null} - + {!hidden && ( ) : null} )} - + ); }; diff --git a/src/screens/reader/components/ChapterDrawer/index.tsx b/src/screens/reader/components/ChapterDrawer/index.tsx index 2aaaf5dbe2..5960b22c7b 100644 --- a/src/screens/reader/components/ChapterDrawer/index.tsx +++ b/src/screens/reader/components/ChapterDrawer/index.tsx @@ -198,7 +198,7 @@ const ChapterDrawer = () => { chapterId: chapter.id, onPress: () => { setLoading(true); - getChapter(val.item); + getChapter({ chapter: val.item }); }, }) } diff --git a/src/screens/reader/components/Hooks/TTSPlaybackManager.ts b/src/screens/reader/components/Hooks/TTSPlaybackManager.ts new file mode 100644 index 0000000000..f2fc26f61a --- /dev/null +++ b/src/screens/reader/components/Hooks/TTSPlaybackManager.ts @@ -0,0 +1,246 @@ +import type { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import * as Speech from 'expo-speech'; +import { NativeEventEmitter, NativeModules } from 'react-native'; + +import { isTikTokVoice } from '../ReaderSettings/TTSVoices'; + +type TTSSettings = NonNullable; + +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) { + // TikTok's speak() replaces the current track itself and plays from its + // buffer cache; TikTokTTS.stop() would wipe that cache, forcing a full + // re-synthesis on pause→resume and seek within the same session. + const keepTikTokBuffers = + previous.id === id && + previous.engine === 'tiktok' && + engine === 'tiktok'; + if (!keepTikTokBuffers) { + 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..b3e48cb09a 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,17 +57,46 @@ export default function useTTS({ const resetAfterPlaybackError = useCallback(() => { autoStartTTSRef.current = false; clearAutoStartTimer(); - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; if (appStateRef.current === 'active') { readingTime.setTTSReading(false); } else { readingTime.stopTTSForBackground(); } dismissTTSNotification(); - webViewRef.current?.injectJavaScript('tts.stop?.()'); + webViewRef.current?.injectJavaScript('tts.setLoading(false); 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 fires when audio is actually playing; loading is shown from + // onWillPlay (in handleSpeak) until then. + onStart: () => + webViewRef.current?.injectJavaScript('tts.setLoading(false)'), + 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 +163,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 +178,36 @@ 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, () => { + webViewRef.current?.injectJavaScript('tts.setLoading(true)'); + 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 +220,7 @@ export default function useTTS({ [readingTime], ); - const stopNativePlayback = useCallback(() => { - stopNativeEngines(); - }, []); + const stopNativePlayback = playback.stopAll; const scheduleAutoStart = useCallback(() => { clearAutoStartTimer(); @@ -360,7 +252,7 @@ export default function useTTS({ () => ({ handleLoadEnd, handlePause, - handleQueue, + handleQueue: playback.handleQueue, handleSpeak, handleState, handleStop, @@ -370,7 +262,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/Hooks/useVolumeButtonScroll.ts b/src/screens/reader/components/Hooks/useVolumeButtonScroll.ts new file mode 100644 index 0000000000..6338b21738 --- /dev/null +++ b/src/screens/reader/components/Hooks/useVolumeButtonScroll.ts @@ -0,0 +1,57 @@ +import NativeVolumeButtonListener from '@specs/NativeVolumeButtonListener'; +import { type RefObject, useCallback, useEffect } from 'react'; +import { Dimensions, NativeEventEmitter } from 'react-native'; +import type WebView from 'react-native-webview'; + +const volumeButtonEmitter = new NativeEventEmitter(NativeVolumeButtonListener); + +type UseVolumeButtonScrollOptions = { + enabled: boolean; + pageReader: boolean; + volumeButtonsOffset: number | null; + webViewRef: RefObject; +}; + +export default function useVolumeButtonScroll({ + enabled, + pageReader, + volumeButtonsOffset, + webViewRef, +}: UseVolumeButtonScrollOptions) { + const move = useCallback( + (direction: -1 | 1) => { + if (pageReader) { + webViewRef.current?.injectJavaScript(` + if (window.pageReader) { + pageReader.movePage(pageReader.page.val + ${direction}); + }`); + return; + } + + const defaultOffset = Math.round(Dimensions.get('window').height * 0.75); + const offset = volumeButtonsOffset ?? defaultOffset; + webViewRef.current?.injectJavaScript(` + window.scrollBy({ top: ${direction * offset}, behavior: 'smooth' }); + `); + }, + [pageReader, volumeButtonsOffset, webViewRef], + ); + + useEffect(() => { + if (!enabled) return; + + const volumeUpSubscription = volumeButtonEmitter.addListener( + 'VolumeUp', + () => move(-1), + ); + const volumeDownSubscription = volumeButtonEmitter.addListener( + 'VolumeDown', + () => move(1), + ); + + return () => { + volumeUpSubscription.remove(); + volumeDownSubscription.remove(); + }; + }, [enabled, move]); +} diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx index abc1a4fb94..f6b2dee76d 100644 --- a/src/screens/reader/components/ReaderAppbar.tsx +++ b/src/screens/reader/components/ReaderAppbar.tsx @@ -1,10 +1,11 @@ import { bookmarkChapter } from '@database/queries/ChapterQueries'; import { useNovelLayout } from '@screens/novel/NovelContext'; +import { resolveUrl } from '@services/plugin/fetch'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import color from 'color'; import React, { useCallback, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Share, StyleSheet, View } from 'react-native'; import { Text } from 'react-native-paper'; import Animated, { Easing, @@ -36,21 +37,40 @@ const ReaderAppbar = ({ openWebView, search, }: ReaderAppbarProps) => { - const { chapter, novel } = useChapterContext(); + const { + chapter, + novel, + reloadFromSource, + canReloadFromSource, + canUseRemoteSource, + } = useChapterContext(); const { statusBarHeight } = useNovelLayout(); const [menuVisible, setMenuVisible] = useState(false); const { openSearch } = search; const openMenu = useCallback(() => setMenuVisible(true), []); const closeMenu = useCallback(() => setMenuVisible(false), []); + const handleReloadFromSource = useCallback(() => { + closeMenu(); + if (!canReloadFromSource) return; + reloadFromSource(); + }, [canReloadFromSource, closeMenu, reloadFromSource]); const handleOpenWebView = useCallback(() => { closeMenu(); + if (!canUseRemoteSource) return; openWebView(); - }, [closeMenu, openWebView]); + }, [canUseRemoteSource, closeMenu, openWebView]); const handleOpenSearch = useCallback(() => { closeMenu(); openSearch(); }, [closeMenu, openSearch]); + const handleShare = useCallback(() => { + closeMenu(); + if (!canUseRemoteSource) return; + Share.share({ + message: resolveUrl(novel.pluginId, chapter.path), + }); + }, [canUseRemoteSource, chapter.path, closeMenu, novel.pluginId]); const entering = () => { 'worklet'; @@ -158,15 +178,25 @@ const ReaderAppbar = ({ } contentStyle={{ backgroundColor: theme.surface2 }} > + + 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); + }); +}); diff --git a/src/screens/reader/utils/htmlGenerator.ts b/src/screens/reader/utils/htmlGenerator.ts index fdaa4ed28f..4976b86883 100644 --- a/src/screens/reader/utils/htmlGenerator.ts +++ b/src/screens/reader/utils/htmlGenerator.ts @@ -4,6 +4,7 @@ import { ChapterReaderSettings, } from '@hooks/persisted/useSettings'; import { ThemeColors } from '@theme/types'; +import { serializeInlineScriptValue } from '@utils/customCode'; import color from 'color'; import { StatusBar } from 'react-native'; @@ -21,6 +22,8 @@ export interface HtmlTemplateOptions { readerBottomInset?: number; pluginCustomCSS?: string; pluginCustomJS?: string; + customCSS?: string; + customJS?: string; nextChapterScreenVisible?: boolean; pendingScrollPosition?: 'start' | 'end' | number | null; readerDir?: 'rtl' | 'ltr'; @@ -48,6 +51,8 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { readerBottomInset = 0, pluginCustomCSS = '', pluginCustomJS = '', + customCSS = '', + customJS = '', nextChapterScreenVisible = false, pendingScrollPosition, readerDir: providedReaderDir, @@ -59,10 +64,6 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { const readerDir = providedReaderDir || (readerSettings.textAlign === 'right' ? 'rtl' : 'ltr'); - // Safe JSON serialization for inline scripts - const safeJsonStringify = (data: any) => - JSON.stringify(data).replace(/ { : ''; const pluginJsScript = - !isSettingsPreview && pluginCustomJS + !isSettingsPreview && readerSettings.pluginUseCustomJS && pluginCustomJS ? `` : ''; const pluginCssLink = - !isSettingsPreview && pluginCustomCSS + !isSettingsPreview && readerSettings.pluginUseCustomCSS && pluginCustomCSS ? `` : ''; + const customJsScript = customJS + ? ` + ` + : ''; + const corePlayerScripts = isVideoChapter ? ` @@ -154,6 +164,12 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { --StatusBar-currentHeight: ${StatusBar.currentHeight}px; --readerSettings-theme: ${readerSettings.theme}; --readerSettings-padding: ${readerSettings.padding}px; + --readerSettings-paragraphIndent: ${ + readerSettings.paragraphIndent ?? 0 + }em; + --readerSettings-paragraphSpacing: ${ + readerSettings.paragraphSpacing ?? 1 + }em; --readerSettings-textSize: ${readerSettings.textSize}px; --readerSettings-textColor: ${readerSettings.textColor}; --readerSettings-textAlign: ${readerSettings.textAlign}; @@ -183,14 +199,14 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { } ${pluginCssLink} - +
    ${chapter.name}
    - ${html} + ${html}
    @@ -203,8 +219,12 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { return true; }; - var initialPageReaderConfig = ${safeJsonStringify(initialPageReaderConfig)}; - var initialReaderConfig = ${safeJsonStringify(initialReaderConfig)}; + var initialPageReaderConfig = ${serializeInlineScriptValue( + initialPageReaderConfig, + )}; + var initialReaderConfig = ${serializeInlineScriptValue( + initialReaderConfig, + )}; @@ -221,9 +241,7 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => { ${proxyFetchScript} ${corePlayerScripts} ${pluginJsScript} - + ${customJsScript} `; }; diff --git a/src/screens/reader/utils/readerWebViewConfig.ts b/src/screens/reader/utils/readerWebViewConfig.ts new file mode 100644 index 0000000000..d5a26152cb --- /dev/null +++ b/src/screens/reader/utils/readerWebViewConfig.ts @@ -0,0 +1,18 @@ +import { getString } from '@strings/translations'; + +export const READER_ASSETS_URI = __DEV__ + ? 'http://localhost:8081/assets' + : 'file:///android_asset'; + +export const createReaderStrings = ( + chapterName?: string | null, + nextChapterName?: string | null, +) => ({ + finished: `${getString('readerScreen.finished')}: ${ + chapterName?.trim() ?? '' + }`, + nextChapter: getString('readerScreen.nextChapter', { + name: nextChapterName, + }), + noNextChapter: getString('readerScreen.noNextChapter'), +}); diff --git a/src/screens/settings/SettingsReaderScreen/Modals/FontPickerModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/FontPickerModal.tsx deleted file mode 100644 index 36be95a392..0000000000 --- a/src/screens/settings/SettingsReaderScreen/Modals/FontPickerModal.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Modal } from '@components'; -import { RadioButton } from '@components/RadioButton/RadioButton'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { readerFonts } from '@utils/constants/readerConstants'; -import React from 'react'; -import { ScrollView, StyleSheet, Text } from 'react-native'; -import { Portal } from 'react-native-paper'; - -interface FontPickerModalProps { - visible: boolean; - onDismiss: () => void; - currentFont: string; -} - -const FontPickerModal: React.FC = ({ - currentFont, - onDismiss, - visible, -}) => { - const theme = useTheme(); - const { setChapterReaderSettings } = useChapterReaderSettings(); - - return ( - - - Font - - {readerFonts.map(item => ( - - setChapterReaderSettings({ fontFamily: item.fontFamily }) - } - label={item.name} - labelStyle={{ fontFamily: item.fontFamily }} - theme={theme} - /> - ))} - - - - ); -}; - -export default FontPickerModal; - -const styles = StyleSheet.create({ - container: { - maxHeight: '70%', - }, - title: { - fontSize: 20, - fontWeight: '600', - marginBottom: 12, - }, - scrollView: { - flexGrow: 0, - }, -}); diff --git a/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx deleted file mode 100644 index 5c8fff692d..0000000000 --- a/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { KeyboardAwareModal, StableTextInput } from '@components'; -import { RadioButton } from '@components/RadioButton/RadioButton'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { LegendList } from '@legendapp/list'; -import { Voice } from 'expo-speech'; -import React, { useState } from 'react'; -import { StyleSheet } from 'react-native'; -import { ActivityIndicator } from 'react-native-paper'; - -interface VoicePickerModalProps { - visible: boolean; - onDismiss: () => void; - voices: Voice[]; -} - -const VoicePickerModal: React.FC = ({ - onDismiss, - visible, - voices, -}) => { - const theme = useTheme(); - const [searchedVoices, setSearchedVoices] = useState([]); - const [searchText, setSearchText] = useState(''); - const { setChapterReaderSettings, tts } = useChapterReaderSettings(); - - return ( - - { - setSearchText(text); - setSearchedVoices( - voices.filter(voice => - voice.name - .toLocaleLowerCase() - .includes(text.toLocaleLowerCase()), - ), - ); - }} - value={searchText} - placeholder="Search voice" - /> - } - ListHeaderComponentStyle={styles.paddingHorizontal} - data={searchText ? searchedVoices : voices} - extraData={tts?.voice} - renderItem={({ item }) => ( - - setChapterReaderSettings({ tts: { ...tts, voice: item } }) - } - label={item.name + ` (${item.language})`} - labelStyle={{ fontFamily: item.name }} - theme={theme} - /> - )} - keyExtractor={(item, index) => - item.identifier || `voice_${index}_${item.name}` - } - estimatedItemSize={64} - ListEmptyComponent={ - - } - /> - - ); -}; - -export default VoicePickerModal; - -const styles = StyleSheet.create({ - containerStyle: { - flex: 1, - }, - contentContainer: { - flex: 1, - }, - paddingHorizontal: { paddingHorizontal: 12 }, - marginTop: { marginTop: 16 }, -}); diff --git a/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx b/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx new file mode 100644 index 0000000000..3ce02dcaeb --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx @@ -0,0 +1,206 @@ +import { + useChapterGeneralSettings, + useChapterReaderSettings, + useTheme, +} from '@hooks/persisted'; +import type { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { getString } from '@strings/translations'; +import { + applyRegexReplacements, + composeCSS, + composeJS, +} from '@utils/customCode'; +import { showToast } from '@utils/showToast'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { StyleSheet } from 'react-native'; +import { getBatteryLevelSync } from 'react-native-device-info'; +import type WebView from 'react-native-webview'; + +import { useReaderSettingsBridge } from '../../reader/components/Hooks/useReaderSettings'; +import useTTSPlayback from '../../reader/components/Hooks/useTTSPlayback'; +import useVolumeButtonScroll from '../../reader/components/Hooks/useVolumeButtonScroll'; +import { parseWebViewEvent } from '../../reader/components/Hooks/webViewEvents'; +import ReaderWebViewCore from '../../reader/components/ReaderWebView/ReaderWebViewCore'; +import { generateReaderHtml } from '../../reader/utils/htmlGenerator'; +import { + createReaderStrings, + READER_ASSETS_URI, +} from '../../reader/utils/readerWebViewConfig'; +import { previewChapter, previewHTML, previewNovel } from './previewFixture'; + +const ReaderPreviewWebView = () => { + const theme = useTheme(); + const readerSettings = useChapterReaderSettings(); + const generalSettings = useChapterGeneralSettings(); + const webViewRef = useRef(null); + const settingsRef = useRef(readerSettings); + const progressRef = useRef(previewChapter.progress ?? 0); + settingsRef.current = readerSettings; + useVolumeButtonScroll({ + enabled: generalSettings.useVolumeButtons, + pageReader: generalSettings.pageReader, + volumeButtonsOffset: generalSettings.volumeButtonsOffset, + webViewRef, + }); + const playback = useTTSPlayback({ + readerSettingsRef: settingsRef, + onDone: () => + webViewRef.current?.injectJavaScript( + 'tts.setLoading(false); tts.next?.();', + ), + onError: error => { + webViewRef.current?.injectJavaScript( + 'tts.setLoading(false); tts.stop?.();', + ); + showToast( + getString( + error === 'voice-required' + ? 'readerSettings.tts.voiceRequiredError' + : error === 'tiktok-unavailable' + ? 'readerSettings.tts.engineUnavailableError' + : 'readerSettings.tts.playbackError', + ), + ); + }, + onInterrupted: () => + webViewRef.current?.injectJavaScript( + 'tts.setLoading(false); tts.stop?.();', + ), + // onStart fires when audio is actually playing; loading is shown from + // onWillPlay (in handleMessage's speak case) until then. + onStart: () => + webViewRef.current?.injectJavaScript('tts.setLoading(false);'), + }); + useReaderSettingsBridge({ + bottomInset: 0, + chapterGeneralSettings: generalSettings, + readerSettings, + stopNativePlayback: playback.stopAll, + webViewRef, + }); + + const latestRef = useRef({ generalSettings, readerSettings }); + latestRef.current = { generalSettings, readerSettings }; + const customCSS = useMemo( + () => composeCSS(readerSettings.codeSnippetsCSS), + [readerSettings.codeSnippetsCSS], + ); + const customJS = useMemo( + () => composeJS(readerSettings.codeSnippetsJS), + [readerSettings.codeSnippetsJS], + ); + const processedPreviewHTML = useMemo( + () => applyRegexReplacements(previewHTML, readerSettings.regexReplacements), + [readerSettings.regexReplacements], + ); + const source = useMemo(() => { + const latest = latestRef.current; + return { + html: generateReaderHtml({ + assetsUriPrefix: READER_ASSETS_URI, + batteryLevel: getBatteryLevelSync(), + chapter: { ...previewChapter, progress: progressRef.current }, + chapterGeneralSettings: latest.generalSettings, + html: processedPreviewHTML, + isSettingsPreview: true, + novel: previewNovel, + readerSettings: latest.readerSettings, + customCSS, + customJS, + strings: createReaderStrings(previewChapter.name), + theme, + }), + }; + }, [customCSS, customJS, processedPreviewHTML, theme]); + + const codeRef = useRef({ + css: customCSS, + js: customJS, + html: processedPreviewHTML, + }); + useEffect(() => { + const changed = + codeRef.current.css !== customCSS || + codeRef.current.js !== customJS || + codeRef.current.html !== processedPreviewHTML; + codeRef.current = { + css: customCSS, + js: customJS, + html: processedPreviewHTML, + }; + if (changed) playback.stop(); + }, [customCSS, customJS, playback, processedPreviewHTML]); + + const handleMessage = useCallback( + (payload: string) => { + const event = parseWebViewEvent(payload); + if (!event) { + return; + } + switch (event.type) { + case 'hide': + webViewRef.current?.injectJavaScript( + 'if (window.reader?.hidden) reader.hidden.val = !reader.hidden.val;', + ); + break; + case 'save': + if (typeof event.data === 'number') { + progressRef.current = event.data; + } + break; + case 'tts-queue': + playback.handleQueue(event); + break; + case 'speak': + if (typeof event.data === 'string' && event.data.trim()) { + playback.handleSpeak(event, () => + webViewRef.current?.injectJavaScript('tts.setLoading(true);'), + ); + } else { + webViewRef.current?.injectJavaScript('tts.next?.();'); + } + break; + case 'pause-speak': + playback.pause(); + break; + case 'stop-speak': + playback.stop(); + break; + case 'console': { + const method = ['debug', 'error', 'info', 'log', 'warn'].includes( + event.method ?? '', + ) + ? (event.method as 'debug' | 'error' | 'info' | 'log' | 'warn') + : 'log'; + // eslint-disable-next-line no-console + console[method]('[Reader preview]', ...(event.args ?? [])); + break; + } + case 'error': + // eslint-disable-next-line no-console + console.error('[Reader preview]', event.msg); + break; + } + }, + [playback], + ); + + return ( + + webViewRef.current?.injectJavaScript( + `if (window.reader?.batteryLevel) reader.batteryLevel.val = ${getBatteryLevelSync()};`, + ) + } + onMessagePayload={handleMessage} + source={source} + style={[styles.webView, { backgroundColor: readerSettings.theme }]} + webViewRef={webViewRef} + /> + ); +}; + +export default React.memo(ReaderPreviewWebView); + +const styles = StyleSheet.create({ webView: { flex: 1 } }); diff --git a/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx b/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx deleted file mode 100644 index 1eea4ad78b..0000000000 --- a/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { IconButtonV2 } from '@components'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import { getString } from '@strings/translations'; -import React from 'react'; -import { StyleSheet, Text, TextStyle, View } from 'react-native'; - -interface ReaderTextSizeProps { - labelStyle?: TextStyle | TextStyle[]; -} - -const ReaderTextSize: React.FC = ({ labelStyle }) => { - const theme = useTheme(); - const { textSize, setChapterReaderSettings } = useChapterReaderSettings(); - - return ( - - - {getString('readerScreen.bottomSheet.textSize')} - - - setChapterReaderSettings({ textSize: textSize - 1 })} - theme={theme} - /> - - {textSize} - - setChapterReaderSettings({ textSize: textSize + 1 })} - theme={theme} - /> - - - ); -}; - -export default ReaderTextSize; - -const styles = StyleSheet.create({ - buttonContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - marginVertical: 6, - paddingHorizontal: 16, - }, - value: { - paddingHorizontal: 24, - }, -}); diff --git a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx index e2ca85e82b..cb8ebaa55b 100644 --- a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx +++ b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx @@ -1,266 +1,44 @@ import { Appbar, SafeAreaView } from '@components'; -import BottomSheet from '@components/BottomSheet/BottomSheet'; -import { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { - useChapterGeneralSettings, - useChapterReaderSettings, - useTheme, -} from '@hooks/persisted'; +import type { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import { useNavigation } from '@react-navigation/native'; import { getString } from '@strings/translations'; -import * as Speech from 'expo-speech'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { StyleSheet, useWindowDimensions, View } from 'react-native'; -import { useBatteryLevel } from 'react-native-device-info'; +import React, { useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; import { FAB } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import WebView from 'react-native-webview'; -import { generateReaderHtml } from '../../reader/utils/htmlGenerator'; -import TabBar, { Tab } from './components/TabBar'; -import AccessibilityTab from './tabs/AccessibilityTab'; -import AdvancedTab from './tabs/AdvancedTab'; -import DisplayTab from './tabs/DisplayTab'; -import NavigationTab from './tabs/NavigationTab'; -import ThemeTab from './tabs/ThemeTab'; -import { dummyHTML } from './utils'; - -export type TextAlignments = - | 'left' - | 'center' - | 'auto' - | 'right' - | 'justify' - | undefined; - -type WebViewPostEvent = { - type: string; - data?: { [key: string]: string | number }; -}; +import ReaderSettingsBottomSheet from '../../reader/components/ReaderSettings/ReaderSettingsBottomSheet'; +import ReaderPreviewWebView from './ReaderPreviewWebView'; const SettingsReaderScreen = () => { const theme = useTheme(); const navigation = useNavigation(); - const webViewRef = useRef(null); - const bottomSheetRef = useRef(null); - const { bottom, right } = useSafeAreaInsets(); - const { height: screenHeight } = useWindowDimensions(); - const [activeTab, setActiveTab] = useState('display'); - - const tabs: Tab[] = [ - { id: 'display', label: 'Display', icon: 'format-size' }, - { id: 'theme', label: 'Theme', icon: 'palette-outline' }, - { id: 'navigation', label: 'Navigation', icon: 'gesture-swipe-horizontal' }, - { id: 'accessibility', label: 'Accessibility', icon: 'account-voice' }, - { id: 'advanced', label: 'Advanced', icon: 'code-braces' }, - ]; - - const novel = { - 'artist': null, - 'author': 'LNReader-kun', - 'cover': - 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181', - 'genres': 'Action,Hero', - 'id': 16, - 'inLibrary': true, - 'isLocal': false, - 'name': 'Preview Man (LN)', - 'path': 'novel/preview-man-16091321', - 'pluginId': 'lightnovelcave', - 'status': 'Ongoing', - 'summary': - 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More', - 'totalPages': 8, - }; - const chapter = { - 'bookmark': false, - 'chapterNumber': 1, - 'id': 3722, - 'isDownloaded': true, - 'name': 'Chapter 1 - The rise of Preview Man', - 'novelId': 16, - 'page': '2', - 'path': 'novel/preview-man/chapter-1', - 'position': 0, - 'progress': 3, - 'readTime': '2100-01-01 00:00:00', - 'releaseTime': 'January 1, 2100', - 'unread': true, - 'updatedTime': null, - }; - const [hidden, setHidden] = useState(true); - const batteryLevel = useBatteryLevel(); const readerSettings = useChapterReaderSettings(); - const chapterGeneralSettings = useChapterGeneralSettings(); - - const BOTTOM_SHEET_HEIGHT = screenHeight * 0.7; - const assetsUriPrefix = useMemo( - () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'), - [], - ); - - const readerBackgroundColor = readerSettings.theme; - - useEffect(() => { - return () => { - Speech.stop(); - }; - }, []); - - const openBottomSheet = () => { - bottomSheetRef.current?.present(); - }; - - const renderTabContent = () => { - switch (activeTab) { - case 'display': - return ; - case 'theme': - return ; - case 'navigation': - return ; - case 'accessibility': - return ; - case 'advanced': - return ; - default: - return ; - } - }; + const bottomSheetRef = useRef(null); + const { bottom, right } = useSafeAreaInsets(); return ( - - {/* Large Preview Area */} - - { - const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); - switch (event.type) { - case 'hide': - if (hidden) { - webViewRef.current?.injectJavaScript( - 'reader.hidden.val = true', - ); - } else { - webViewRef.current?.injectJavaScript( - 'reader.hidden.val = false', - ); - } - setHidden(!hidden); - break; - case 'speak': - if (event.data && typeof event.data === 'string') { - Speech.speak(event.data, { - onDone() { - webViewRef.current?.injectJavaScript('tts.next?.()'); - }, - voice: readerSettings.tts?.voice?.identifier, - pitch: readerSettings.tts?.pitch || 1, - rate: readerSettings.tts?.rate || 1, - }); - } else { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - break; - case 'stop-speak': - Speech.stop(); - break; - } - }} - source={{ - html: generateReaderHtml({ - html: dummyHTML, - theme, - readerSettings, - chapterGeneralSettings, - novel, - chapter, - nextChapter: chapter, - prevChapter: chapter, - assetsUriPrefix, - batteryLevel, - isSettingsPreview: true, - strings: { - finished: `${getString( - 'readerScreen.finished', - )}: ${chapter.name?.trim()}`, - nextChapter: getString('readerScreen.nextChapter', { - name: chapter.name, - }), - noNextChapter: getString('readerScreen.noNextChapter'), - }, - }), - }} - /> + + - - {/* Floating Action Button to Open Bottom Sheet */} bottomSheetRef.current?.present()} + style={[styles.fab, { backgroundColor: theme.primary, bottom, right }]} /> - - {/* Bottom Sheet with Tabs */} - - - {/* Drag Handle */} - - - - - {/* Tab Bar */} - - - {/* Tab Content */} - {renderTabContent()} - - + ); }; @@ -268,33 +46,7 @@ const SettingsReaderScreen = () => { export default SettingsReaderScreen; const styles = StyleSheet.create({ - fab: { - position: 'absolute', - margin: 16, - }, - bottomSheetContent: { - flex: 1, - }, - dragHandleContainer: { - alignItems: 'center', - paddingVertical: 12, - }, - dragHandle: { - width: 32, - height: 4, - borderRadius: 2, - opacity: 0.4, - }, - tabContent: { - flex: 1, - }, - container: { - flex: 1, - }, - previewContainer: { - flex: 1, - }, - webView: { - flex: 1, - }, + container: { flex: 1 }, + fab: { margin: 16, position: 'absolute' }, + preview: { flex: 1 }, }); diff --git a/src/screens/settings/SettingsReaderScreen/components/TabBar.tsx b/src/screens/settings/SettingsReaderScreen/components/TabBar.tsx deleted file mode 100644 index 0d349ede99..0000000000 --- a/src/screens/settings/SettingsReaderScreen/components/TabBar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; -import { ThemeColors } from '@theme/types'; -import React from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; - -export interface Tab { - id: string; - label: string; - icon: React.ComponentProps['name']; -} - -interface TabBarProps { - tabs: Tab[]; - activeTab: string; - onTabChange: (tabId: string) => void; - theme: ThemeColors; -} - -const TabBar: React.FC = ({ - tabs, - activeTab, - onTabChange, - theme, -}) => { - return ( - - {tabs.map(tab => { - const isActive = activeTab === tab.id; - return ( - onTabChange(tab.id)} - android_ripple={{ - color: theme.rippleColor, - borderless: false, - }} - > - - - ); - })} - - ); -}; - -export default TabBar; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - borderBottomWidth: 1, - borderBottomColor: 'rgba(0, 0, 0, 0.12)', - backgroundColor: 'transparent', // Will be set by theme if needed, but the lint was about static style - }, - tab: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - paddingHorizontal: 8, - minHeight: 48, - borderBottomWidth: 2, - }, -}); diff --git a/src/screens/settings/SettingsReaderScreen/previewFixture.ts b/src/screens/settings/SettingsReaderScreen/previewFixture.ts new file mode 100644 index 0000000000..50a7bd2d3f --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/previewFixture.ts @@ -0,0 +1,38 @@ +import type { ChapterInfo, NovelInfo } from '@database/types'; + +import { dummyHTML } from './utils'; + +export const previewNovel: Partial = { + artist: null, + author: 'LNReader-kun', + genres: 'Action,Hero', + id: 16, + inLibrary: true, + isLocal: false, + name: 'Preview Man (LN)', + path: 'novel/preview-man-16091321', + pluginId: 'lightnovelcave', + status: 'Ongoing', + summary: + 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More', + totalPages: 8, +}; + +export const previewChapter: Partial = { + bookmark: false, + chapterNumber: 1, + id: 3722, + isDownloaded: true, + name: 'Chapter 1 - The rise of Preview Man', + novelId: 16, + page: '2', + path: 'novel/preview-man/chapter-1', + position: 0, + progress: 3, + readTime: '2100-01-01 00:00:00', + releaseTime: 'January 1, 2100', + unread: true, + updatedTime: null, +}; + +export const previewHTML = dummyHTML; diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx deleted file mode 100644 index b1804fa65a..0000000000 --- a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { List } from '@components'; -import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; -import { useChapterGeneralSettings, useTheme } from '@hooks/persisted'; -import { getString } from '@strings/translations'; -import React from 'react'; -import { StyleSheet, View } from 'react-native'; - -import SettingSwitch from '../../components/SettingSwitch'; - -const AccessibilityTab: React.FC = () => { - const theme = useTheme(); - const { - fullScreenMode = true, - showScrollPercentage = true, - showBatteryAndTime = false, - keepScreenOn = true, - bionicReading = false, - setChapterGeneralSettings, - } = useChapterGeneralSettings(); - - return ( - - - - {getString('common.display')} - - - setChapterGeneralSettings({ fullScreenMode: !fullScreenMode }) - } - theme={theme} - /> - - setChapterGeneralSettings({ - showScrollPercentage: !showScrollPercentage, - }) - } - theme={theme} - /> - - setChapterGeneralSettings({ - showBatteryAndTime: !showBatteryAndTime, - }) - } - theme={theme} - /> - - setChapterGeneralSettings({ keepScreenOn: !keepScreenOn }) - } - theme={theme} - /> - - - - Reading Enhancements - - setChapterGeneralSettings({ bionicReading: !bionicReading }) - } - theme={theme} - /> - - - - - ); -}; - -export default AccessibilityTab; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - contentContainer: { - paddingBottom: 24, - }, - section: { - marginVertical: 8, - }, - bottomSpacing: { - height: 24, - }, -}); diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx deleted file mode 100644 index 779937f934..0000000000 --- a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { Button, ConfirmationDialog, StableTextInput } from '@components'; -import { - BottomSheetScrollView, - BottomSheetTextInput, -} from '@gorhom/bottom-sheet'; -import { useBoolean } from '@hooks'; -import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; -import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; -import NativeFile from '@specs/NativeFile'; -import { getString } from '@strings/translations'; -import { ThemeColors } from '@theme/types'; -import { showToast } from '@utils/showToast'; -import * as DocumentPicker from 'expo-document-picker'; -import React, { useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { Portal } from 'react-native-paper'; - -type CodeTab = 'css' | 'js'; - -const AdvancedTab: React.FC = () => { - const theme = useTheme(); - const styles = createStyles(theme); - const { customCSS, customJS, setChapterReaderSettings } = - useChapterReaderSettings(); - - const [activeCodeTab, setActiveCodeTab] = useState('css'); - const [cssValue, setCssValue] = useState(customCSS || ''); - const [jsValue, setJsValue] = useState(customJS || ''); - - const clearCSSModal = useBoolean(); - const clearJSModal = useBoolean(); - - const customCSSPlaceholder = `/* Custom CSS for your reader */ - -body { - margin: 16px; - line-height: 1.8; -} - -h1, h2, h3 { - margin-top: 1.5em; - margin-bottom: 0.5em; - font-weight: bold; -} - -p { - text-indent: 1em; - margin-bottom: 1em; -} - -/* Target specific sources */ -#sourceId-example { - font-family: serif; -}`; - - const customJSPlaceholder = `// Custom JavaScript for your reader -// Available variables: -// - html, novelName, chapterName -// - sourceId, chapterId, novelId - -// Example: Remove elements -document.querySelectorAll('.ads').forEach(el => el.remove()); - -// Example: Modify content -const title = document.querySelector('h1'); -if (title) { - title.style.color = '#FF6B6B'; -}`; - - const handleSave = () => { - if (activeCodeTab === 'css') { - setChapterReaderSettings({ customCSS: cssValue }); - } else { - setChapterReaderSettings({ customJS: jsValue }); - } - showToast('Saved'); - }; - - const handleReset = () => { - if (activeCodeTab === 'css') { - clearCSSModal.setTrue(); - } else { - clearJSModal.setTrue(); - } - }; - - const confirmResetCSS = () => { - setCssValue(''); - setChapterReaderSettings({ customCSS: '' }); - clearCSSModal.setFalse(); - }; - - const confirmResetJS = () => { - setJsValue(''); - setChapterReaderSettings({ customJS: '' }); - clearJSModal.setFalse(); - }; - - const handleImport = async () => { - try { - // Android can be very picky with MIME types, especially for JS/CSS files - // created on Windows. Using */* allows selecting any file. - const file = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: false, - type: '*/*', - }); - - if (file.assets && file.assets.length > 0) { - const fileName = file.assets[0].name.toLowerCase(); - - // Filter by extension manually because Android intent only filters by MIME type natively - if (activeCodeTab === 'css' && !fileName.endsWith('.css')) { - showToast('Please select a valid CSS file.'); - return; - } else if (activeCodeTab === 'js' && !fileName.endsWith('.js')) { - showToast('Please select a valid JS file.'); - return; - } - - const tempPath = - NativeFile.getConstants().ExternalCachesDirectoryPath + - '/imported_custom.' + - activeCodeTab; - NativeFile.copyFile(file.assets[0].uri, tempPath); - const content = NativeFile.readFile(tempPath); - NativeFile.unlink(tempPath); - - if (activeCodeTab === 'css') { - setCssValue(content.trim()); - setChapterReaderSettings({ customCSS: content.trim() }); - } else { - setJsValue(content.trim()); - setChapterReaderSettings({ customJS: content.trim() }); - } - showToast('Imported'); - } - } catch (error: any) { - showToast(error.message); - } - }; - - return ( - <> - - {/* Tab Selector */} - - setActiveCodeTab('css')} - android_ripple={{ color: theme.rippleColor }} - > - - - CSS - - - - setActiveCodeTab('js')} - android_ripple={{ color: theme.rippleColor }} - > - - - JS - - - - - {/* Code Editor */} - - } - mode="flat" - value={activeCodeTab === 'css' ? cssValue : jsValue} - onChangeText={text => - activeCodeTab === 'css' ? setCssValue(text) : setJsValue(text) - } - placeholder={ - activeCodeTab === 'css' - ? customCSSPlaceholder - : customJSPlaceholder - } - multiline - numberOfLines={12} - autoCorrect={false} - autoCapitalize="none" - spellCheck={false} - style={[styles.codeEditor, { backgroundColor: theme.surface2 }]} - activeUnderlineColor={theme.primary} - contentStyle={styles.codeEditorContent} - textColor={theme.onSurface} - placeholderTextColor={theme.onSurfaceVariant} - /> - - - {/* Hint */} - - - - {activeCodeTab === 'css' - ? getString('readerSettings.cssHint') - : getString('readerSettings.jsHint')} - - - - {/* Action Buttons */} - -