Compare commits

...

21 Commits

Author SHA1 Message Date
Rajarshee Chatterjee d43c95f98c chore: Release v2.0.2
[skip ci]
2025-12-07 00:44:30 +05:30
Rajarshee Chatterjee 52d9d9ea1d feat: Continue Reading While App Runs in Background 2025-12-06 19:03:44 +05:30
Rajarshee Chatterjee 2cabc9849c fix: Skip Failed Entities During Backup Creation and Add Error Handling 2025-12-06 15:16:22 +05:30
Rajarshee Chatterjee 17049aca1c feat: Use All Chapters in Jump-to-Chapter Modal 2025-12-06 14:39:17 +05:30
Rajarshee Chatterjee 9608d59efe fix: Update Battery Level on Chapter Change (Closes #1650) 2025-12-06 13:27:19 +05:30
Rajarshee Chatterjee a7942212a0 fix: Show Page Names Instead of Numbers (Closes #1647) 2025-12-06 13:09:39 +05:30
Rajarshee Chatterjee a22aa53c51 fix: Add Unique Keys to All LegendList Implementations 2025-12-06 13:03:00 +05:30
Rajarshee Chatterjee 1ce16dc643 chore: Cleanup Unused Imports 2025-12-06 12:56:51 +05:30
Rajarshee Chatterjee 0ed6e9a463 fix: Start TTS From First Paragraph on Initial Press 2025-12-06 12:55:02 +05:30
Rajarshee Chatterjee b7ecd1d749 chore: Update Website Link (Closes #1666) 2025-12-06 12:48:42 +05:30
Rajarshee Chatterjee 44e177a5ca feat: Allow <ref> Links in Sanitised HTML (Closes #1672) 2025-12-06 12:48:21 +05:30
Rajarshee Chatterjee b09cd3d587 fix: Sort Updates Correctly (Closes #1652) 2025-12-06 12:44:11 +05:30
Rajarshee Chatterjee b70ebcf5f9 chore: Bump Version Patch to 1 in build.gradle (Closes #1662) 2025-12-06 12:20:19 +05:30
Gabriel Vasconcelos 9d58161729 feat(tts): Refactor Reading Logic to Fix Overflow and Add New Settings (#1664)
feat(tts): refactor reading logic and add new settings (voice, speed, pitch, auto page advance)

- Improved how TTS reads page content for smoother playback
- Added settings for voice selection, speed, and pitch control
- Added auto page advance support
- Added scroll-to-top behavior after advancing pages
- Updated UI to expose new TTS configuration options

[skip ci]
2025-12-06 12:18:35 +05:30
Rajarshee Chatterjee e07eed4a95 fix: Prevent App Crash in Offline Mode (Closes #1651) 2025-12-06 12:14:54 +05:30
Patrick Loser e09704c711 fix: Replace react native paper components
* updated debs

* Added Custom Menu component

* Added new BottomTabBar

---------

Co-authored-by: CD-Z <github.verify097@passmail.com>
2025-11-29 12:39:01 +01:00
Rajarshee Chatterjee 3af24f0815 fix: Improve Migration Error Handling 2025-11-16 13:18:58 +05:30
Rajarshee Chatterjee 4c0e7f6013 fix: Add Backward Compatibility to Tasks Queue 2025-11-16 12:36:42 +05:30
Rajarshee Chatterjee ea298273af fix: Categories Not Sorting
Closes #1642
2025-11-15 20:54:55 +05:30
Rajarshee Chatterjee f60a5af3f2 fix: Add Error Handling Around PRAGMA Query 2025-11-15 20:29:05 +05:30
Rajarshee Chatterjee 6ae5c1cdac chore: Release v2.0.1
[skip ci]
2025-11-15 15:37:34 +05:30
44 changed files with 2967 additions and 1733 deletions
+3 -3
View File
@@ -45,7 +45,7 @@ body:
label: LNReader version
description: You can find your LNReader version in **More → About**.
placeholder: |
Example: "2.0.0"
Example: "2.0.2"
validations:
required: true
@@ -86,9 +86,9 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
required: true
- label: I have updated the app to version **[2.0.0](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.2](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+1 -1
View File
@@ -34,7 +34,7 @@ body:
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/LNReader/lnreader-sources/issues/new/choose).
required: true
- label: I have updated the app to version **[2.0.0](https://github.com/LNReader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.0.2](https://github.com/LNReader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2
View File
@@ -88,3 +88,5 @@ flake.lock
# pnpm
.pnpm-store
.cursor/
+1 -1
View File
@@ -75,7 +75,7 @@ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
ext {
versionMajor = 2
versionMinor = 0
versionPatch = 0
versionPatch = 2
}
android {
+149 -20
View File
@@ -140,6 +140,10 @@ window.tts = new (function () {
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
this.elementsRead = 0;
this.totalElements = 0;
this.allReadableElements = []; // Store all readable elements at start
this.textQueue = []; // Flat list of normalized text for native fallback
this.readable = element => {
const ele = element ?? this.currentElement;
@@ -169,22 +173,41 @@ window.tts = new (function () {
};
// if can find a readable node, else stop tts
this.findNextTextNode = () => {
// FIXED: Added proper boundary checks to prevent stack overflow
this.findNextTextNode = (depth = 0) => {
// Prevent deep recursion
if (depth > 500) {
console.warn('TTS: findNextTextNode max depth reached');
return false;
}
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
return false;
} else {
this.started = true;
}
// Safety check: ensure currentElement is valid
if (!this.currentElement || !this.currentElement.nodeName) {
return false;
}
// is read, have to go next or go back
if (this.currentElement.isSameNode(this.prevElement)) {
this.prevElement = this.currentElement;
if (this.currentElement.nextElementSibling) {
this.currentElement = this.currentElement.nextElementSibling;
} else {
return this.findNextTextNode(depth + 1);
} else if (
this.currentElement.parentElement &&
!this.currentElement.parentElement.isSameNode(document.body) &&
!this.currentElement.parentElement.isSameNode(document.documentElement)
) {
this.currentElement = this.currentElement.parentElement;
return this.findNextTextNode(depth + 1);
} else {
return false;
}
return this.findNextTextNode();
} else {
// can read? read it
if (this.readable()) {
@@ -197,50 +220,117 @@ window.tts = new (function () {
// go deep
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.firstElementChild;
return this.findNextTextNode(depth + 1);
} else if (this.currentElement.nextElementSibling) {
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.nextElementSibling;
} else {
return this.findNextTextNode(depth + 1);
} else if (
this.currentElement.parentElement &&
!this.currentElement.parentElement.isSameNode(document.body) &&
!this.currentElement.parentElement.isSameNode(document.documentElement)
) {
this.prevElement = this.currentElement;
this.currentElement = this.currentElement.parentElement;
return this.findNextTextNode(depth + 1);
} else {
return false;
}
return this.findNextTextNode();
}
};
this.next = () => {
try {
this.currentElement?.classList?.remove('highlight');
if (this.findNextTextNode()) {
const text = this.normalizeText(this.currentElement?.innerText);
// Use array-based approach instead of DOM traversal (no recursion!)
while (this.elementsRead < this.totalElements) {
const nextElement = this.allReadableElements[this.elementsRead];
if (!nextElement) break;
const text = this.normalizeText(nextElement.innerText);
if (text) {
// Found valid text - speak it
this.currentElement = nextElement;
this.reading = true;
this.elementsRead++;
this.speak();
return;
} else {
this.next();
// Empty text, skip to next in array (no recursion!)
this.elementsRead++;
}
}
// Reached the end (elementsRead >= totalElements or no more valid elements)
this.reading = false;
const autoPageAdvance =
reader.readerSettings.val.tts?.autoPageAdvance === true;
const hasNextChapter = !!reader.nextChapter;
if (autoPageAdvance && hasNextChapter) {
reader.post({ type: 'next', autoStartTTS: true });
} else {
this.reading = false;
this.stop();
const controller = document.getElementById('TTS-Controller');
if (controller?.firstElementChild) {
controller.firstElementChild.innerHTML = volumnIcon;
}
if (reader.nextChapter) {
reader.post({ type: 'next', autoStartTTS: true });
}
}
} catch (e) {
alert(e);
this.stop();
alert('TTS Error: ' + e.message);
}
};
this.start = element => {
this.stop();
this.currentElement = element ?? reader.chapterElement;
const startElement = element ?? reader.chapterElement;
this.currentElement = startElement;
// Get all readable elements from the chapter
this.allReadableElements = this.getAllReadableElements(
reader.chapterElement,
);
this.totalElements = this.allReadableElements.length;
this.textQueue = this.allReadableElements
.map(el => this.normalizeText(el.innerText))
.filter(text => !!text);
reader.post({
type: 'tts-queue',
data: {
queue: this.textQueue,
startIndex: this.elementsRead,
},
});
// If starting from a specific element, count how many are before it
if (element && element !== reader.chapterElement) {
const startIndex = this.allReadableElements.indexOf(element);
this.elementsRead = startIndex >= 0 ? startIndex : 0;
} else {
this.elementsRead = 0;
}
this.next();
};
// Get all readable elements in order
this.getAllReadableElements = element => {
const elements = [];
const traverse = el => {
if (!el) return;
if (this.readable(el)) {
elements.push(el);
}
for (let i = 0; i < el.children.length; i++) {
traverse(el.children[i]);
}
};
traverse(element);
return elements;
};
this.resume = () => {
if (!this.reading) {
if (
@@ -268,7 +358,16 @@ window.tts = new (function () {
this.currentElement = reader.chapterElement;
this.started = false;
this.reading = false;
this.elementsRead = 0;
this.totalElements = 0;
this.allReadableElements = [];
this.textQueue = [];
reader.post({ type: 'tts-state', data: { isReading: false } });
// Ensure icon updates to stopped state
const controller = document.getElementById('TTS-Controller');
if (controller?.firstElementChild) {
controller.firstElementChild.innerHTML = volumnIcon;
}
};
this.isElementInViewport = element => {
@@ -287,6 +386,7 @@ window.tts = new (function () {
);
};
// UPDATED: Scroll to top or center based on settings with padding for notch/camera
this.scrollToElement = element => {
if (!element) return;
// Check if element is partially visible (at least some part is in viewport)
@@ -301,11 +401,27 @@ window.tts = new (function () {
// Only scroll if element is not visible or barely visible
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
// Check scrollToTop setting (default to true for better reading experience)
const scrollToTop = reader.readerSettings.val.tts?.scrollToTop !== false;
if (scrollToTop) {
// Scroll to top with padding for notch/camera (80px from top)
const elementTop =
element.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementTop - 80; // 80px padding for notch/camera
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
} else {
// Center scroll (original behavior)
element.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
};
@@ -316,7 +432,11 @@ window.tts = new (function () {
this.currentElement.classList.add('highlight');
const text = this.normalizeText(this.currentElement.innerText);
if (text) {
reader.post({ type: 'speak', data: text });
reader.post({
type: 'speak',
data: text,
index: this.elementsRead - 1,
});
reader.post({ type: 'tts-state', data: { isReading: true } });
} else {
this.next();
@@ -324,6 +444,15 @@ window.tts = new (function () {
};
})();
// Watch for TTSEnable changes and stop TTS when disabled
van.derive(() => {
if (!reader.generalSettings.val.TTSEnable && window.tts) {
if (tts.reading || tts.started) {
tts.stop();
}
}
});
window.pageReader = new (function () {
this.page = van.state(0);
this.totalPages = van.state(0);
+14 -2
View File
@@ -264,6 +264,10 @@ const TTSController = () => {
{
id: 'TTS-Controller',
class: () => `${reader.generalSettings.val.TTSEnable ? '' : 'hidden'}`,
style: () =>
reader.generalSettings.val.TTSEnable
? 'pointer-events: auto;'
: 'pointer-events: none; display: none !important; opacity: 0; transition: none;',
ontouchstart: () => {
if (!controllerElement) {
controllerElement = document.getElementById('TTS-Controller');
@@ -303,7 +307,8 @@ const TTSController = () => {
top = reader.layoutHeight - 120;
}
controllerElement.style.top = `${top}px`;
if (hoverElement) {
// Check if TTS is still enabled before starting
if (hoverElement && reader.generalSettings.val.TTSEnable) {
tts.start(hoverElement);
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
@@ -313,12 +318,19 @@ const TTSController = () => {
},
onclick: e => {
e.stopPropagation();
// Don't allow interaction if TTS is disabled
if (!reader.generalSettings.val.TTSEnable) {
return;
}
if (tts.reading) {
tts.pause();
controllerElement.firstElementChild.innerHTML = resumeIcon;
} else {
} else if (tts.started) {
tts.resume();
controllerElement.firstElementChild.innerHTML = pauseIcon;
} else {
tts.start();
controllerElement.firstElementChild.innerHTML = pauseIcon;
}
},
},
+38 -38
View File
@@ -1,6 +1,6 @@
{
"name": "lnreader",
"version": "2.0.0",
"version": "2.0.2",
"private": true,
"scripts": {
"dev:android": "pnpm run generate:env:debug && react-native run-android --appIdSuffix \"debug\" --active-arch-only",
@@ -41,84 +41,84 @@
"dependencies": {
"@cd-z/react-native-epub-creator": "^3.0.0",
"@gorhom/bottom-sheet": "^5.2.6",
"@legendapp/list": "^2.0.14",
"@react-native-community/slider": "^5.0.1",
"@legendapp/list": "^2.0.16",
"@react-native-community/slider": "^5.1.1",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-documents/picker": "^10.1.7",
"@react-native-google-signin/google-signin": "^16.0.0",
"@react-native-vector-icons/common": "^12.3.0",
"@react-native-vector-icons/material-design-icons": "^12.3.0",
"@react-native/codegen": "^0.81.1",
"@react-native/gradle-plugin": "^0.81.1",
"@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.26",
"@react-navigation/stack": "^7.4.8",
"@react-native-vector-icons/common": "^12.4.0",
"@react-native-vector-icons/material-design-icons": "^12.4.0",
"@react-native/codegen": "^0.81.5",
"@react-native/gradle-plugin": "^0.81.5",
"@react-navigation/bottom-tabs": "^7.8.6",
"@react-navigation/native": "^7.1.21",
"@react-navigation/native-stack": "^7.8.0",
"@react-navigation/stack": "^7.6.7",
"@shopify/flash-list": "2.0.2",
"cheerio": "1.0.0-rc.12",
"color": "^5.0.0",
"dayjs": "^1.11.18",
"expo": "^54.0.2",
"color": "^5.0.3",
"dayjs": "^1.11.19",
"expo": "^54.0.25",
"expo-clipboard": "~8.0.7",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.12",
"expo-file-system": "~19.0.19",
"expo-haptics": "~15.0.7",
"expo-keep-awake": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-linking": "~8.0.9",
"expo-localization": "~17.0.7",
"expo-navigation-bar": "~5.0.8",
"expo-notifications": "~0.32.11",
"expo-navigation-bar": "~5.0.9",
"expo-notifications": "~0.32.13",
"expo-speech": "~14.0.7",
"expo-sqlite": "~16.0.8",
"expo-web-browser": "~15.0.7",
"expo-sqlite": "~16.0.9",
"expo-web-browser": "~15.0.9",
"htmlparser2": "^10.0.0",
"i18n-js": "^4.5.1",
"lodash-es": "^4.17.21",
"lottie-ios": "^3.5.0",
"protobufjs": "^7.5.4",
"react": "19.1.0",
"react-native": "^0.81.4",
"react-native": "^0.81.5",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^14.1.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.1.13",
"react-native-drawer-layout": "^4.2.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-error-boundary": "^2.0.0",
"react-native-file-access": "^3.2.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-gesture-handler": "^2.29.1",
"react-native-lottie-splash-screen": "^1.1.2",
"react-native-mmkv": "^3.3.1",
"react-native-mmkv": "^3.3.3",
"react-native-pager-view": "^6.9.1",
"react-native-paper": "^5.14.5",
"react-native-reanimated": "^4.1.0",
"react-native-reanimated": "^4.1.5",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "^4.16.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.18.0",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^4.1.3",
"react-native-tab-view": "^4.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-webview": "13.15.0",
"react-native-worklets": "^0.5.1",
"react-native-worklets": "^0.6.1",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.0",
"urlencode": "^2.0.0"
},
"devDependencies": {
"@babel/core": "^7.28.4",
"@babel/core": "^7.28.5",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@react-native-community/cli": "^20.0.2",
"@react-native-community/cli-platform-android": "^20.0.2",
"@react-native-community/cli-platform-ios": "^20.0.2",
"@react-native/babel-preset": "^0.81.1",
"@react-native/eslint-config": "^0.81.1",
"@react-native/metro-config": "^0.81.1",
"@react-native/typescript-config": "^0.81.1",
"@react-native/babel-preset": "^0.81.5",
"@react-native/eslint-config": "^0.81.5",
"@react-native/metro-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@types/color": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/react": "~19.1.12",
"@types/react": "~19.1.17",
"@types/sanitize-html": "^2.16.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -127,7 +127,7 @@
"lint-staged": "^12.5.0",
"prettier": "2.8.8",
"react-native-dotenv": "^3.4.11",
"typescript": "~5.9.2"
"typescript": "~5.9.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
+1097 -1095
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/* eslint-disable react-native/no-inline-styles */
import React, { useCallback } from 'react';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Pressable, View, StyleSheet } from 'react-native';
import { Text } from 'react-native-paper';
import { ThemeColors } from '@theme/types';
import Animated from 'react-native-reanimated';
interface CustomBottomTabBarProps extends BottomTabBarProps {
theme: ThemeColors;
showLabelsInNav: boolean;
renderIcon: ({
color,
route,
}: {
route: { name: string };
color: string;
}) => React.ReactNode;
}
function CustomBottomTabBar({
navigation,
state,
descriptors,
insets,
theme,
showLabelsInNav,
renderIcon,
}: CustomBottomTabBarProps) {
const getLabelText = useCallback(
(route: any) => {
if (!showLabelsInNav && route.name !== state.routeNames[state.index]) {
return '';
}
const { options } = descriptors[route.key];
const label =
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: typeof options.title === 'string'
? options.title
: route.name;
return label;
},
[descriptors, showLabelsInNav, state.index, state.routeNames],
);
return (
<View
style={[
styles.container,
{
backgroundColor: theme.surface2 || theme.surface,
paddingBottom: insets?.bottom || 0,
},
]}
>
{state.routes.map((route, index) => {
const label = getLabelText(route);
const isFocused = state.index === index;
const showLabel = (showLabelsInNav || isFocused) && label;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name, route.params);
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
const iconColor = isFocused
? theme.onPrimaryContainer
: theme.onSurfaceVariant;
return (
<Pressable
key={route.key}
onPress={onPress}
onLongPress={onLongPress}
style={styles.pressable}
>
{/* Icon */}
<Animated.View
style={[
styles.iconContainer,
{
transitionProperty: ['width', 'backgroundColor'],
transitionDuration: 250,
transitionTimingFunction: 'ease-in-out',
marginBottom: showLabel ? 4 : 20,
width: isFocused ? 52 : 32,
backgroundColor: isFocused
? theme.primaryContainer
: 'transparent',
},
]}
>
{renderIcon({ color: iconColor, route })}
</Animated.View>
{/* Label */}
{showLabel ? (
<Text
style={[
styles.label,
{
color: theme.onSurfaceVariant,
fontWeight: '700',
},
]}
numberOfLines={1}
>
{label}
</Text>
) : null}
</Pressable>
);
})}
</View>
);
}
export default CustomBottomTabBar;
export type { CustomBottomTabBarProps };
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingVertical: 4,
paddingHorizontal: 0,
minHeight: 80,
},
pressable: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 6,
paddingHorizontal: 4,
position: 'relative',
},
iconContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 2,
borderRadius: 24,
},
label: {
height: 16,
fontSize: 12,
textAlign: 'center',
},
});
+202
View File
@@ -0,0 +1,202 @@
import { useTheme } from '@hooks/persisted';
import React, { useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, View, Dimensions } from 'react-native';
import { Portal } from 'react-native-paper';
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
FadeOutUp,
useAnimatedStyle,
} from 'react-native-reanimated';
const { width: screenWidth } = Dimensions.get('window');
interface MenuProps {
visible: boolean;
onDismiss: () => void;
anchor: React.ReactNode;
contentStyle?: any;
children: React.ReactNode;
}
interface MenuItemProps {
title: string;
onPress: () => void;
style?: any;
titleStyle?: any;
}
const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
visible,
onDismiss,
anchor,
contentStyle,
children,
}) => {
const theme = useTheme();
const anchorRef = useRef<View>(null);
const [anchorLayout, setAnchorLayout] = useState({
x: 0,
y: 0,
width: 0,
height: 0,
});
const [isMeasured, setIsMeasured] = useState(false);
const menuAnimatedStyle = useAnimatedStyle(() => ({
shadowColor: theme.isDark ? '#000' : theme.shadow,
position: 'absolute' as const,
left: Math.max(16, Math.min(anchorLayout.x, screenWidth - 220)),
top: anchorLayout.y + anchorLayout.height + 8,
width: Math.min(200, screenWidth - 32),
zIndex: 1001,
}));
// Create entering animations
const backdropEntering = FadeIn.duration(150);
const menuEntering = FadeInUp.duration(150)
.springify()
.damping(30)
.stiffness(500)
.mass(0.3)
.withInitialValues({
transform: [{ translateY: -10 }],
});
// Create exiting animations
const backdropExiting = FadeOut.duration(150);
const menuExiting = FadeOutUp.duration(150)
.springify()
.damping(30)
.stiffness(500)
.mass(0.3);
const measureAnchor = React.useCallback(() => {
if (anchorRef.current) {
anchorRef.current.measure((x, y, width, height, pageX, pageY) => {
setAnchorLayout({ x: pageX, y: pageY, width, height });
setIsMeasured(true);
});
}
}, []);
// Measure anchor on mount
useEffect(() => {
setTimeout(measureAnchor, 0);
}, [measureAnchor]);
if (!visible) {
return (
<View ref={anchorRef} collapsable={false} onLayout={measureAnchor}>
{anchor}
</View>
);
}
return (
<>
<View ref={anchorRef} collapsable={false} onLayout={measureAnchor}>
{anchor}
</View>
{visible && isMeasured && (
<Portal>
{/* Backdrop */}
<Pressable style={StyleSheet.absoluteFillObject} onPress={onDismiss}>
<Animated.View
style={[
styles.backdrop,
{
backgroundColor: theme.isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(0, 0, 0, 0.1)',
},
]}
entering={backdropEntering}
exiting={backdropExiting}
/>
</Pressable>
{/* Menu */}
<Animated.View
style={[
styles.menuContainer,
menuAnimatedStyle,
{ backgroundColor: theme.surface },
contentStyle,
]}
entering={menuEntering}
exiting={menuExiting}
>
{children}
</Animated.View>
</Portal>
)}
</>
);
};
const MenuItem: React.FC<MenuItemProps> = ({
title,
onPress,
style,
titleStyle,
}) => {
const theme = useTheme();
return (
<Pressable
style={[styles.menuItem, style]}
onPress={onPress}
android_ripple={{ color: theme.rippleColor, foreground: true }}
>
<Animated.Text
style={[
styles.menuItemText,
{
color: theme.onSurface,
},
titleStyle,
]}
>
{title}
</Animated.Text>
</Pressable>
);
};
Menu.Item = MenuItem;
const styles = StyleSheet.create({
menuContainer: {
borderRadius: 8,
elevation: 8,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 6,
overflow: 'hidden',
},
backdrop: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
menuItem: {
paddingHorizontal: 16,
paddingVertical: 12,
minHeight: 48,
justifyContent: 'center',
},
menuItemText: {
fontSize: 16,
fontWeight: '400',
},
});
export default Menu;
+5 -4
View File
@@ -3,10 +3,10 @@ import { Pressable, StyleSheet, View, TextInput } from 'react-native';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
import { ThemeColors } from '../../theme/types';
import { Menu } from 'react-native-paper';
import { Menu } from '@components';
import { MaterialDesignIconName } from '@type/icon';
interface RightIcon {
export interface RightIcon {
iconName: MaterialDesignIconName;
color?: string;
onPress: () => void;
@@ -112,7 +112,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
anchorPosition="bottom"
anchor={
<IconButtonV2
name="dots-vertical"
@@ -135,7 +134,9 @@ const Searchbar: React.FC<SearcbarProps> = ({
}}
onPress={() => {
showExtraMenu(false);
button.onPress();
setTimeout(() => {
button.onPress();
}, 0);
}}
/>
))}
+2
View File
@@ -18,3 +18,5 @@ export { default as SafeAreaView } from './SafeAreaView/SafeAreaView';
export { default as Modal } from './Modal/Modal';
export { SegmentedControl } from './SegmentedControl';
export { DialogTitle } from './DialogTitle';
export { default as BottomTabBar } from './BottomTabBar';
export { default as Menu } from './Menu';
+19 -5
View File
@@ -31,7 +31,7 @@ export const db = SQLite.openDatabaseSync(dbName);
/**
* Creates the initial database schema for fresh installations
* Sets up all tables, indexes, triggers and sets version to 1
* Sets up all tables, indexes, triggers and sets version to 2
*/
const createInitialSchema = () => {
db.execSync('PRAGMA journal_mode = WAL');
@@ -52,7 +52,7 @@ const createInitialSchema = () => {
db.runSync(createNovelTriggerQueryUpdate);
db.runSync(createNovelTriggerQueryDelete);
db.execSync('PRAGMA user_version = 1');
db.execSync('PRAGMA user_version = 2');
});
};
@@ -65,9 +65,23 @@ export const initializeDatabase = () => {
db.execSync('PRAGMA cache_size = 10000');
db.execSync('PRAGMA foreign_keys = ON');
const userVersion =
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0;
let userVersion = 0;
try {
const result = db.getFirstSync<{ user_version: number }>(
'PRAGMA user_version',
);
userVersion = result?.user_version ?? 0;
} catch (error) {
// If PRAGMA query fails, assume fresh database
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
'Failed to get database version, assuming fresh install:',
error,
);
}
userVersion = 0;
}
if (userVersion === 0) {
createInitialSchema();
+101 -84
View File
@@ -7,10 +7,14 @@ const columnExists = (
tableName: string,
columnName: string,
): boolean => {
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
try {
const columns = db.getAllSync<{ name: string }>(
`PRAGMA table_info(${tableName})`,
);
return columns.some(col => col.name === columnName);
} catch {
return false;
}
};
/**
@@ -23,86 +27,99 @@ export const migration002: Migration = {
version: 2,
description: 'Add counter columns and triggers to Novel table',
migrate: db => {
if (!columnExists(db, 'Novel', 'chaptersDownloaded')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN chaptersDownloaded INTEGER DEFAULT 0
`);
const addColumnSafely = (columnName: string, columnDefinition: string) => {
if (!columnExists(db, 'Novel', columnName)) {
try {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN ${columnName} ${columnDefinition}
`);
} catch (error) {
// Gracefully handle ALTER TABLE failures (e.g., table doesn't exist)
// Columns will be created when table is created in initial schema
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
`Failed to add column ${columnName} to Novel table:`,
error,
);
}
}
}
};
addColumnSafely('chaptersDownloaded', 'INTEGER DEFAULT 0');
addColumnSafely('chaptersUnread', 'INTEGER DEFAULT 0');
addColumnSafely('totalChapters', 'INTEGER DEFAULT 0');
addColumnSafely('lastReadAt', 'TEXT');
addColumnSafely('lastUpdatedAt', 'TEXT');
// Verify all columns exist before running UPDATE queries to prevent SQL errors
const allColumnsExist =
columnExists(db, 'Novel', 'chaptersDownloaded') &&
columnExists(db, 'Novel', 'chaptersUnread') &&
columnExists(db, 'Novel', 'totalChapters') &&
columnExists(db, 'Novel', 'lastReadAt') &&
columnExists(db, 'Novel', 'lastUpdatedAt');
if (allColumnsExist) {
try {
db.runSync(`
UPDATE Novel
SET chaptersDownloaded = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.isDownloaded = 1
)
`);
db.runSync(`
UPDATE Novel
SET chaptersUnread = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.unread = 1
)
`);
db.runSync(`
UPDATE Novel
SET totalChapters = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastReadAt = (
SELECT MAX(readTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastUpdatedAt = (
SELECT MAX(updatedTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
} catch (error) {
// Gracefully handle UPDATE failures - columns already added with defaults
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
'Failed to populate counter columns in Novel table:',
error,
);
}
}
}
if (!columnExists(db, 'Novel', 'chaptersUnread')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN chaptersUnread INTEGER DEFAULT 0
`);
}
if (!columnExists(db, 'Novel', 'totalChapters')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN totalChapters INTEGER DEFAULT 0
`);
}
if (!columnExists(db, 'Novel', 'lastReadAt')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN lastReadAt TEXT
`);
}
if (!columnExists(db, 'Novel', 'lastUpdatedAt')) {
db.runSync(`
ALTER TABLE Novel
ADD COLUMN lastUpdatedAt TEXT
`);
}
db.runSync(`
UPDATE Novel
SET chaptersDownloaded = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.isDownloaded = 1
)
`);
db.runSync(`
UPDATE Novel
SET chaptersUnread = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
AND Chapter.unread = 1
)
`);
db.runSync(`
UPDATE Novel
SET totalChapters = (
SELECT COUNT(*)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastReadAt = (
SELECT MAX(readTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
db.runSync(`
UPDATE Novel
SET lastUpdatedAt = (
SELECT MAX(updatedTime)
FROM Chapter
WHERE Chapter.novelId = Novel.id
)
`);
},
};
+4 -8
View File
@@ -375,7 +375,7 @@ export const getUpdatedOverviewFromDb = () =>
Novel.name AS novelName,
Novel.cover AS novelCover,
Novel.path AS novelPath,
DATE(Chapter.updatedTime) AS updateDate, -- Extract the date from updatedTime
DATE(Chapter.updatedTime) AS updateDate,
COUNT(*) AS updatesPerDay
FROM
Chapter
@@ -387,14 +387,10 @@ WHERE
Chapter.updatedTime IS NOT NULL
GROUP BY
Novel.id,
Novel.name,
Novel.cover,
Novel.path,
DATE(Chapter.updatedTime) -- Group by date and novelId
DATE(Chapter.updatedTime)
ORDER BY
novelId,
updateDate;
updateDate DESC,
novelId;
`);
export const getDetailedUpdatesFromDb = async (
+8 -6
View File
@@ -321,10 +321,12 @@ export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
Object.values(novel) as string[] | number[],
],
]);
runAsync(
chapters.map(chapter => [
restoreObjectQuery('Chapter', chapter),
Object.values(chapter) as string[] | number[],
]),
);
if (chapters.length > 0) {
await runAsync(
chapters.map(chapter => [
restoreObjectQuery('Chapter', chapter),
Object.values(chapter) as string[] | number[],
]),
);
}
};
+13 -4
View File
@@ -49,10 +49,19 @@ export class MigrationRunner {
}
private getCurrentVersion(db: SQLiteDatabase): number {
return (
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
?.user_version ?? 0
);
try {
const result = db.getFirstSync<{ user_version: number }>(
'PRAGMA user_version',
);
return result?.user_version ?? 0;
} catch (error) {
// If PRAGMA query fails, assume version 0 (fresh database)
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn('Failed to get database version, assuming 0:', error);
}
return 0;
}
}
private setVersion(db: SQLiteDatabase, version: number): void {
+20 -13
View File
@@ -2,8 +2,6 @@ import { useState, useEffect } from 'react';
import { version } from '../../../package.json';
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { showToast } from '@utils/showToast';
import { getErrorMessage } from '@utils/error';
interface GithubUpdate {
isNewVersion: boolean;
@@ -40,12 +38,23 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
try {
const res = await fetch(latestReleaseUrl);
if (!res.ok) {
setChecking(false);
return;
}
const data = await res.json();
if (!data || !data.tag_name) {
setChecking(false);
return;
}
const release = {
tag_name: data.tag_name,
body: data.body,
downloadUrl: data.assets[0].browser_download_url,
downloadUrl: data.assets?.[0]?.browser_download_url || undefined,
};
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
@@ -53,7 +62,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
setLatestRelease(release);
setChecking(false);
} catch (error) {
showToast(`Failed to check for updates: ${getErrorMessage(error)}`);
// Silently fail in offline mode or on network errors
setChecking(false);
}
};
@@ -71,17 +80,15 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
checkForRelease();
}, []);
if (!checking && shouldCheckForUpdate()) {
const data = {
if (!checking && latestRelease?.tag_name) {
return {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
return data;
} else {
return {
latestRelease: undefined,
isNewVersion: false,
};
}
return {
latestRelease: undefined,
isNewVersion: false,
};
};
+20 -2
View File
@@ -110,6 +110,8 @@ export interface ChapterReaderSettings {
voice?: Voice;
rate?: number;
pitch?: number;
autoPageAdvance?: boolean;
scrollToTop?: boolean;
};
epubLocation: string;
epubUseAppTheme: boolean;
@@ -180,7 +182,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
removeExtraParagraphSpacing: false,
bionicReading: false,
tapToScroll: false,
TTSEnable: false,
TTSEnable: true,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
@@ -197,6 +199,8 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
tts: {
rate: 1,
pitch: 1,
autoPageAdvance: false,
scrollToTop: true,
},
epubLocation: '',
epubUseAppTheme: false,
@@ -268,9 +272,23 @@ export const useChapterGeneralSettings = () => {
};
export const useChapterReaderSettings = () => {
const [chapterReaderSettings = initialChapterReaderSettings, setSettings] =
const [storedSettings = initialChapterReaderSettings, setSettings] =
useMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS);
// Ensure TTS settings have proper defaults (migration for existing users)
const chapterReaderSettings = {
...storedSettings,
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,
},
};
const setChapterReaderSettings = (values: Partial<ChapterReaderSettings>) =>
setSettings({ ...chapterReaderSettings, ...values });
+10 -56
View File
@@ -1,8 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import {
BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Library from '../screens/library/LibraryScreen';
import Updates from '../screens/updates/UpdatesScreen';
@@ -15,8 +12,7 @@ import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted';
import { BottomNavigatorParamList } from './types';
import Icon from '@react-native-vector-icons/material-design-icons';
import { MaterialDesignIconName } from '@type/icon';
import { CommonActions } from '@react-navigation/native';
import { BottomNavigation } from 'react-native-paper';
import { BottomTabBar } from '@components';
const Tab = createBottomTabNavigator<BottomNavigatorParamList>();
@@ -63,55 +59,6 @@ const BottomNavigator = () => {
[],
);
const renderBottomBar = useCallback(
({ navigation, state, descriptors, insets }: BottomTabBarProps) => (
<BottomNavigation.Bar
theme={{ colors: theme }}
style={{
backgroundColor: theme.surface2,
}}
navigationState={state}
safeAreaInsets={insets}
onTabPress={({ route, preventDefault }) => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (event.defaultPrevented) {
preventDefault();
} else {
navigation.dispatch({
...CommonActions.navigate(route.name, route.params),
target: state.key,
});
}
}}
renderIcon={renderIcon}
getLabelText={({ route }) => {
if (
!showLabelsInNav &&
route.name !== state.routeNames[state.index]
) {
return '';
}
const { options } = descriptors[route.key];
const label =
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: typeof options.title === 'string'
? options.title
: route.name;
return label;
}}
/>
),
[renderIcon, showLabelsInNav, theme],
);
return (
<Tab.Navigator
screenOptions={() => ({
@@ -123,7 +70,14 @@ const BottomNavigator = () => {
color: theme.onError,
},
})}
tabBar={renderBottomBar}
tabBar={props => (
<BottomTabBar
{...props}
theme={theme}
showLabelsInNav={showLabelsInNav}
renderIcon={renderIcon}
/>
)}
>
<Tab.Screen
name="Library"
+9 -5
View File
@@ -53,11 +53,15 @@ const CategoriesScreen = () => {
const systemCategories = categories.filter(cat => cat.id === 1);
const updatedOrderCategories = [...systemCategories, ...data];
requestAnimationFrame(() => {
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
});
const updatedOrderCategories = [...systemCategories, ...data].map(
(category, index) => ({
...category,
sort: index,
}),
);
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
};
const renderItem = ({
@@ -53,6 +53,7 @@ const FirstRoute = () => {
estimatedItemSize={4}
extraData={[filter]}
data={libraryFilterList}
keyExtractor={item => `filter_${item.filter}`}
renderItem={({ item }) => (
<Checkbox
label={item.label}
@@ -85,6 +86,7 @@ const SecondRoute = () => {
data={librarySortOrderList}
extraData={[sortOrder]}
estimatedItemSize={5}
keyExtractor={(item, index) => `sort_${index}_${item.ASC}`}
renderItem={({ item }) => (
<SortItem
label={item.label}
@@ -161,6 +163,7 @@ const ThirdRoute = () => {
estimatedItemSize={4}
data={displayModesList}
extraData={[displayMode]}
keyExtractor={item => `display_mode_${item.value}`}
renderItem={({ item }) => (
<RadioButton
label={item.label}
+20 -10
View File
@@ -9,7 +9,10 @@ import { Category, NovelInfo } from '@database/types';
import { useLibrarySettings } from '@hooks/persisted';
import { LibrarySortOrder } from '../constants/constants';
import { switchNovelToLibraryQuery } from '@database/queries/NovelQueries';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import ServiceManager, {
BackgroundTask,
QueuedBackgroundTask,
} from '@services/ServiceManager';
import { useMMKVObject } from 'react-native-mmkv';
// type Library = Category & { novels: LibraryNovelInfo[] };
@@ -106,17 +109,24 @@ export const useLibrary = (): UseLibraryReturnType => {
getLibrary();
});
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
);
const [taskQueue] = useMMKVObject<
Array<BackgroundTask | QueuedBackgroundTask>
>(ServiceManager.manager.STORE_KEY);
const restoreTasksCount = useMemo(
() =>
taskQueue?.filter(
t =>
t.name === 'LOCAL_RESTORE' ||
t.name === 'DRIVE_RESTORE' ||
t.name === 'SELF_HOST_RESTORE',
).length || 0,
taskQueue?.filter(t => {
/**
* Handle backward compatibility: check for new format first, then old format
*/
const taskName =
(t as QueuedBackgroundTask)?.task?.name ||
(t as BackgroundTask)?.name;
return (
taskName === 'LOCAL_RESTORE' ||
taskName === 'DRIVE_RESTORE' ||
taskName === 'SELF_HOST_RESTORE'
);
}).length || 0,
[taskQueue],
);
const prevRestoreTasksCountRef = useRef(restoreTasksCount);
+2 -2
View File
@@ -59,8 +59,8 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
<List.Divider theme={theme} />
<List.Item
title={getString('aboutScreen.website')}
description="https://lnreader.github.io"
onPress={() => Linking.openURL('https://lnreader.github.io')}
description="https://lnreader.app"
onPress={() => Linking.openURL('https://lnreader.app')}
theme={theme}
/>
<List.Item
-1
View File
@@ -318,7 +318,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
chapters={chapters}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
StyleSheet,
View,
@@ -17,11 +17,11 @@ import {
LegendListRef,
LegendListRenderItemProps,
} from '@legendapp/list';
import { getNovelChapters } from '@database/queries/ChapterQueries';
interface JumpToChapterModalProps {
hideModal: () => void;
modalVisible: boolean;
chapters: ChapterInfo[];
navigation: NovelScreenProps['navigation'];
novel: NovelInfo;
chapterListRef: React.RefObject<LegendListRef | null>;
@@ -30,16 +30,14 @@ interface JumpToChapterModalProps {
const JumpToChapterModal = ({
hideModal,
modalVisible,
chapters,
navigation,
novel,
chapterListRef,
}: JumpToChapterModalProps) => {
const minNumber = Math.min(...chapters.map(c => c.chapterNumber || -1));
const maxNumber = Math.max(...chapters.map(c => c.chapterNumber || -1));
const theme = useTheme();
const [mode, setMode] = useState(false);
const [openChapter, setOpenChapter] = useState(false);
const [allChapters, setAllChapters] = useState<ChapterInfo[]>([]);
const [text, setText] = useState('');
const [error, setError] = useState('');
@@ -48,6 +46,30 @@ const JumpToChapterModal = ({
const inputRef = useRef<RNTextInput>(null);
const [inputFocused, setInputFocused] = useState(false);
useEffect(() => {
if (modalVisible && novel?.id) {
getNovelChapters(novel.id).then(chapters => {
setAllChapters(chapters);
});
}
}, [modalVisible, novel?.id]);
const minNumber = useMemo(() => {
if (allChapters.length === 0) {
return 1;
}
return Math.min(...allChapters.map(c => c.chapterNumber || -1));
}, [allChapters]);
const maxNumber = useMemo(() => {
if (allChapters.length === 0) {
return 1;
}
return Math.max(...allChapters.map(c => c.chapterNumber || -1));
}, [allChapters]);
const onDismiss = () => {
hideModal();
setText('');
@@ -67,20 +89,21 @@ const JumpToChapterModal = ({
const scrollToChapter = (chap: ChapterInfo) => {
onDismiss();
chapterListRef.current?.scrollToItem({
animated: true,
item: chap,
viewPosition: 0.5,
});
};
const index = allChapters.findIndex(c => c.id === chap.id);
const scrollToIndex = (index: number) => {
onDismiss();
chapterListRef.current?.scrollToIndex({
animated: true,
index: index,
viewPosition: 0.5,
});
if (index !== -1) {
chapterListRef.current?.scrollToIndex({
animated: true,
index: index,
viewPosition: 0.5,
});
} else {
chapterListRef.current?.scrollToItem({
animated: true,
item: chap,
viewPosition: 0.5,
});
}
};
const executeFunction = (item: ChapterInfo) => {
@@ -118,13 +141,15 @@ const JumpToChapterModal = ({
const num = Number(text);
if (num && num >= minNumber && num <= maxNumber) {
if (openChapter) {
const chapter = chapters.find(c => c.chapterNumber === num);
const chapter = allChapters.find(c => c.chapterNumber === num);
if (chapter) {
return navigateToChapter(chapter);
}
} else {
const index = chapters.findIndex(c => c.chapterNumber === num);
return scrollToIndex(index);
const chapter = allChapters.find(c => c.chapterNumber === num);
if (chapter) {
return scrollToChapter(chapter);
}
}
}
return setError(
@@ -132,7 +157,7 @@ const JumpToChapterModal = ({
` (${num < minNumber ? '≥ ' + minNumber : '≤ ' + maxNumber})`,
);
} else {
const searchedChapters = chapters.filter(chap =>
const searchedChapters = allChapters.filter(chap =>
chap.name.toLowerCase().includes(text?.toLowerCase()),
);
@@ -226,6 +251,7 @@ const JumpToChapterModal = ({
data={result}
extraData={openChapter}
renderItem={renderItem}
keyExtractor={item => `chapter_${item.id}`}
contentContainerStyle={styles.listContentCtn}
/>
</View>
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { memo, useCallback, useMemo, useState } from 'react';
import { getString } from '@strings/translations';
import { Appbar, Menu as DefaultMenu } from 'react-native-paper';
import { Appbar } from 'react-native-paper';
import { Menu as DefaultMenu } from '@components';
import { ThemeColors } from '@theme/types';
import Animated, {
FadeIn,
@@ -35,7 +36,7 @@ const Menu = React.memo(
visible={visible}
onDismiss={onDismiss}
anchor={anchor}
anchorPosition="bottom"
// anchorPosition="bottom"
contentStyle={{ backgroundColor: theme.surface2 }}
>
{items.map((item, index) => (
@@ -63,6 +63,7 @@ export default function NovelDrawer({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `novel_page_${index}_${item}`}
estimatedItemSize={60}
/>
</View>
@@ -331,9 +331,9 @@ const NovelScreenList = ({
hasMultiplePages ? (
<View>
<PagePaginationControl
currentPage={pageIndex + 1}
totalPages={pages.length}
onPageChange={pageNum => openPage(pageNum - 1)}
pages={pages}
currentPageIndex={pageIndex}
onPageChange={openPage}
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
theme={theme}
/>
@@ -95,6 +95,7 @@ export default function PageNavigationBottomSheet({
recycleItems
extraData={pageIndex}
renderItem={renderItem}
keyExtractor={(item, index) => `page_${index}_${item}`}
estimatedItemSize={56}
contentContainerStyle={styles.listContent}
/>
@@ -7,72 +7,74 @@ import { Row } from '@components/Common';
import { borderColor } from '@theme/colors';
interface PagePaginationControlProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
pages: string[];
currentPageIndex: number;
onPageChange: (pageIndex: number) => void;
onOpenDrawer: () => void;
theme: ThemeColors;
}
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
currentPage,
totalPages,
pages,
currentPageIndex,
onPageChange,
onOpenDrawer,
theme,
}) => {
const pageNumbers = useMemo(() => {
const pages: (number | 'ellipsis')[] = [];
const totalPages = pages.length;
const pageIndices = useMemo(() => {
const indices: (number | 'ellipsis')[] = [];
if (totalPages <= 3) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
for (let i = 0; i < totalPages; i++) {
indices.push(i);
}
} else {
if (currentPage !== 1) {
pages.push(1);
if (currentPageIndex !== 0) {
indices.push(0);
}
const leftPage = currentPage - 1;
if (leftPage > 1) {
if (leftPage > 2) {
pages.push('ellipsis');
const leftPageIndex = currentPageIndex - 1;
if (leftPageIndex > 0) {
if (leftPageIndex > 1) {
indices.push('ellipsis');
}
pages.push(leftPage);
indices.push(leftPageIndex);
}
pages.push(currentPage);
indices.push(currentPageIndex);
if (currentPage < totalPages - 1) {
pages.push('ellipsis');
if (currentPageIndex < totalPages - 1) {
indices.push('ellipsis');
}
if (totalPages !== currentPage) {
pages.push(totalPages);
if (currentPageIndex !== totalPages - 1) {
indices.push(totalPages - 1);
}
}
return pages;
}, [currentPage, totalPages]);
return indices;
}, [currentPageIndex, totalPages]);
const canGoPrevious = currentPage > 1;
const canGoNext = currentPage < totalPages;
const canGoPrevious = currentPageIndex > 0;
const canGoNext = currentPageIndex < totalPages - 1;
const handlePrevious = () => {
if (canGoPrevious) {
onPageChange(currentPage - 1);
onPageChange(currentPageIndex - 1);
}
};
const handleNext = () => {
if (canGoNext) {
onPageChange(currentPage + 1);
onPageChange(currentPageIndex + 1);
}
};
const handlePagePress = (page: number) => {
if (page !== currentPage) {
onPageChange(page);
const handlePagePress = (pageIndex: number) => {
if (pageIndex !== currentPageIndex) {
onPageChange(pageIndex);
}
};
@@ -101,8 +103,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
</Pressable>
<Row style={styles.pageNumbersRow}>
{pageNumbers.map((page, index) => {
if (page === 'ellipsis') {
{pageIndices.map((pageIndex, index) => {
if (pageIndex === 'ellipsis') {
return (
<Pressable
key={`ellipsis-${index}`}
@@ -124,10 +126,11 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
);
}
const isActive = page === currentPage;
const isActive = pageIndex === currentPageIndex;
const pageName = pages[pageIndex];
return (
<Pressable
key={page}
key={`page-${pageIndex}`}
style={[
styles.button,
{
@@ -135,7 +138,7 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
backgroundColor: isActive ? theme.primary : theme.surface,
},
]}
onPress={() => handlePagePress(page)}
onPress={() => handlePagePress(pageIndex)}
android_ripple={{
color: isActive
? color(theme.onPrimary).alpha(0.2).string()
@@ -150,8 +153,9 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
fontWeight: isActive ? '600' : '400',
},
]}
numberOfLines={1}
>
{page}
{pageName}
</Text>
</Pressable>
);
@@ -226,6 +230,7 @@ const styles = StyleSheet.create({
pageText: {
fontSize: 15,
letterSpacing: 0.15,
maxWidth: 100,
},
});
@@ -159,7 +159,9 @@ const ChapterDrawer = () => {
onViewableItemsChanged={checkViewableItems}
data={chapters}
extraData={[chapter, scrollToIndex.current]}
keyExtractor={item => (item.position ?? item.id).toString()}
keyExtractor={item =>
`chapter_${item.id}_${item.position ?? 'no_pos'}`
}
renderItem={val =>
renderListChapter({
item: val.item,
@@ -27,6 +27,7 @@ import ReaderThemeSelector from './ReaderThemeSelector';
import ReaderTextAlignSelector from './ReaderTextAlignSelector';
import ReaderValueChange from './ReaderValueChange';
import ReaderFontPicker from './ReaderFontPicker';
import TTSTab from './TTSTab';
import { overlay } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
@@ -130,6 +131,7 @@ interface ReaderBottomSheetV2Props {
const routes = [
{ key: 'readerTab', title: getString('readerSettings.title') },
{ key: 'generalTab', title: getString('generalSettings') },
{ key: 'ttsTab', title: 'TTS' },
];
const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
@@ -143,7 +145,7 @@ const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
const backgroundColor = tabHeaderColor;
const renderScene = useMemo(
() => SceneMap({ readerTab: ReaderTab, generalTab: GeneralTab }),
() => SceneMap({ readerTab: ReaderTab, generalTab: GeneralTab, ttsTab: TTSTab }),
[],
);
@@ -0,0 +1,443 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { View, StyleSheet, Text, ScrollView, TouchableOpacity } from 'react-native';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import Slider from '@react-native-community/slider';
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
import { getLocales } from 'expo-localization';
import {
useTheme,
useChapterGeneralSettings,
useChapterReaderSettings,
} from '@hooks/persisted';
import { getString } from '@strings/translations';
import { List, Button } from '@components/index';
import { Portal, Modal, Chip } from 'react-native-paper';
import ReaderSheetPreferenceItem from './ReaderSheetPreferenceItem';
interface VoicePickerModalProps {
visible: boolean;
onDismiss: () => void;
voices: Voice[];
onSelect: (voice: Voice) => void;
currentVoice?: Voice;
}
const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
visible,
onDismiss,
voices,
onSelect,
currentVoice
}) => {
const theme = useTheme();
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);
// Get system language safely using getLocales()
const systemLocale = getLocales()[0]?.languageCode || 'en';
// Get unique languages from voices
const availableLanguages = useMemo(() => {
const languages = new Set<string>();
voices.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);
});
}, [voices, systemLocale]);
// Filter voices by selected languages
const filteredVoices = useMemo(() => {
if (selectedLanguages.length === 0) {
// Show system language voices by default
return voices.filter(voice => {
if (voice.name === 'System') return true;
const lang = voice.language?.split('-')[0];
return lang === systemLocale;
});
}
return voices.filter(voice => {
if (voice.name === 'System') return true;
const lang = voice.language?.split('-')[0];
return lang && selectedLanguages.includes(lang);
});
}, [voices, 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 (
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
contentContainerStyle={[
styles.modalContent,
{ backgroundColor: theme.surface }
]}
>
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
Select Voice
</Text>
{/* Language Filter */}
<View style={styles.languageFilterContainer}>
<Text style={[styles.filterLabel, { color: theme.onSurfaceVariant }]}>
Filter by language:
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageChipsScroll}
>
{availableLanguages.map(lang => {
const isSelected = selectedLanguages.includes(lang);
const isSystemLang = lang === systemLocale;
const showingSystemOnly = selectedLanguages.length === 0;
const isActive = isSelected || (showingSystemOnly && isSystemLang);
return (
<Chip
key={lang}
selected={isActive}
onPress={() => toggleLanguage(lang)}
style={[
styles.languageChip,
isActive && { backgroundColor: theme.primary }
]}
textStyle={{
color: isActive ? theme.onPrimary : theme.onSurface,
fontSize: 12
}}
>
{lang.toUpperCase()}
{isSystemLang && ' (System)'}
</Chip>
);
})}
</ScrollView>
</View>
{/* Voice List */}
<ScrollView style={styles.voiceList}>
{filteredVoices.length === 0 ? (
<Text style={[styles.noVoicesText, { color: theme.onSurfaceVariant }]}>
No voices available for selected languages
</Text>
) : (
filteredVoices.map((voice: Voice, index: number) => (
<TouchableOpacity
key={index}
style={[
styles.voiceItem,
currentVoice?.identifier === voice.identifier && {
backgroundColor: theme.surfaceVariant,
}
]}
onPress={() => {
onSelect(voice);
onDismiss();
}}
>
<View style={styles.voiceItemContent}>
<Text style={[styles.voiceItemText, { color: theme.onSurface }]}>
{voice.name}
</Text>
{voice.language && (
<Text style={[styles.voiceItemLanguage, { color: theme.onSurfaceVariant }]}>
{voice.language}
</Text>
)}
</View>
{currentVoice?.identifier === voice.identifier && (
<Text style={{ color: theme.primary, fontSize: 16 }}>✓</Text>
)}
</TouchableOpacity>
))
)}
</ScrollView>
<Button
title="Cancel"
mode="outlined"
onPress={onDismiss}
style={styles.cancelButton}
/>
</Modal>
</Portal>
);
};
const TTSTab: React.FC = () => {
const theme = useTheme();
const {
TTSEnable = true,
setChapterGeneralSettings,
} = useChapterGeneralSettings();
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
const [voices, setVoices] = useState<Voice[]>([]);
const [voiceModalVisible, setVoiceModalVisible] = useState(false);
useEffect(() => {
getAvailableVoicesAsync().then(res => {
res.sort((a, b) => a.name.localeCompare(b.name));
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
});
}, []);
const handleVoiceSelect = useCallback((voice: Voice) => {
setChapterReaderSettings({ tts: { ...tts, voice } });
}, [tts, setChapterReaderSettings]);
return (
<>
<BottomSheetScrollView
style={styles.container}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.section}>
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
<ReaderSheetPreferenceItem
label="Enable TTS"
value={TTSEnable}
onPress={() =>
setChapterGeneralSettings({ TTSEnable: !TTSEnable })
}
theme={theme}
/>
{TTSEnable && (
<>
<TouchableOpacity
style={styles.settingItem}
onPress={() => setVoiceModalVisible(true)}
>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice
</Text>
<Text style={[styles.value, { color: theme.onSurfaceVariant }]}>
{tts?.voice?.name || 'System'}
</Text>
</TouchableOpacity>
<View style={styles.sliderSection}>
<Text style={[styles.sliderLabel, { color: theme.onSurface }]}>
Speed: {tts?.rate?.toFixed(1) || '1.0'}x
</Text>
<Slider
style={styles.slider}
value={tts?.rate || 1}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, rate: value } })
}
/>
</View>
<View style={styles.sliderSection}>
<Text style={[styles.sliderLabel, { color: theme.onSurface }]}>
Pitch: {tts?.pitch?.toFixed(1) || '1.0'}
</Text>
<Slider
style={styles.slider}
value={tts?.pitch || 1}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
}
/>
</View>
<ReaderSheetPreferenceItem
label="Auto Page Advance"
value={tts?.autoPageAdvance === true}
onPress={() =>
setChapterReaderSettings({
tts: { ...tts, autoPageAdvance: !(tts?.autoPageAdvance === true) },
})
}
theme={theme}
/>
<ReaderSheetPreferenceItem
label="Scroll to Top"
value={tts?.scrollToTop !== false}
onPress={() =>
setChapterReaderSettings({
tts: { ...tts, scrollToTop: !(tts?.scrollToTop !== false) },
})
}
theme={theme}
/>
<View style={styles.resetButtonContainer}>
<Button
title={getString('common.reset')}
mode="outlined"
onPress={() => {
setChapterReaderSettings({
tts: {
pitch: 1,
rate: 1,
voice: { name: 'System', language: 'System' } as Voice,
autoPageAdvance: false,
scrollToTop: true,
},
});
}}
style={styles.resetButton}
/>
</View>
</>
)}
</View>
<View style={styles.bottomSpacing} />
</BottomSheetScrollView>
<VoicePickerModal
visible={voiceModalVisible}
onDismiss={() => setVoiceModalVisible(false)}
voices={voices}
onSelect={handleVoiceSelect}
currentVoice={tts?.voice}
/>
</>
);
};
export default React.memo(TTSTab);
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingBottom: 24,
},
section: {
marginVertical: 8,
},
settingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
},
label: {
fontSize: 16,
},
value: {
fontSize: 14,
},
sliderSection: {
paddingHorizontal: 16,
paddingVertical: 12,
},
sliderLabel: {
fontSize: 16,
marginBottom: 8,
},
slider: {
height: 40,
},
resetButtonContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
resetButton: {
alignSelf: 'flex-start',
},
bottomSpacing: {
height: 24,
},
modalContent: {
margin: 20,
borderRadius: 8,
padding: 20,
maxHeight: '80%',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 12,
},
languageFilterContainer: {
marginBottom: 16,
},
filterLabel: {
fontSize: 12,
marginBottom: 8,
},
languageChipsScroll: {
flexGrow: 0,
},
languageChip: {
marginRight: 8,
marginBottom: 8,
},
voiceList: {
maxHeight: 350,
marginTop: 8,
},
voiceItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 12,
borderRadius: 4,
marginBottom: 4,
},
voiceItemContent: {
flex: 1,
},
voiceItemText: {
fontSize: 16,
marginBottom: 4,
},
voiceItemLanguage: {
fontSize: 12,
},
noVoicesText: {
textAlign: 'center',
padding: 20,
fontSize: 14,
},
cancelButton: {
marginTop: 16,
},
});
+148 -18
View File
@@ -1,5 +1,10 @@
import React, { memo, useEffect, useMemo, useRef } from 'react';
import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
AppState,
NativeEventEmitter,
NativeModules,
StatusBar,
} from 'react-native';
import WebView from 'react-native-webview';
import color from 'color';
@@ -30,8 +35,9 @@ import {
type WebViewPostEvent = {
type: string;
data?: { [key: string]: string | number };
data?: { [key: string]: unknown };
autoStartTTS?: boolean;
index?: number;
};
type WebViewReaderProps = {
@@ -67,13 +73,11 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
webViewRef,
} = useChapterContext();
const theme = useTheme();
const readerSettings = useMemo(
// Use state for settings so they update when MMKV changes
const [readerSettings, setReaderSettings] = useState<ChapterReaderSettings>(
() =>
getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
initialChapterReaderSettings,
// needed to preserve settings during chapter change
// eslint-disable-next-line react-hooks/exhaustive-deps
[chapter.id],
);
const chapterGeneralSettings = useMemo(
() =>
@@ -83,13 +87,31 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
[chapter.id],
);
const batteryLevel = useMemo(() => getBatteryLevelSync(), []);
// Update readerSettings when chapter changes
useEffect(() => {
setReaderSettings(
getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
initialChapterReaderSettings,
);
}, [chapter.id]);
// Update battery level when chapter changes to ensure fresh value on navigation
const batteryLevel = useMemo(() => getBatteryLevelSync(), [chapter.id]);
const plugin = getPlugin(novel?.pluginId);
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
const nextChapterScreenVisible = useRef<boolean>(false);
const autoStartTTSRef = useRef<boolean>(false);
const isTTSReadingRef = useRef<boolean>(false);
const readerSettingsRef = useRef<ChapterReaderSettings>(readerSettings);
const appStateRef = useRef(AppState.currentState);
const ttsQueueRef = useRef<string[]>([]);
const ttsQueueIndexRef = useRef<number>(0);
useEffect(() => {
readerSettingsRef.current = readerSettings;
}, [readerSettings]);
useEffect(() => {
const checkNotificationActions = setInterval(() => {
@@ -151,10 +173,33 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
const mmkvListener = MMKVStorage.addOnValueChangedListener(key => {
switch (key) {
case CHAPTER_READER_SETTINGS:
// Update local state with new settings
const newSettings =
getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
initialChapterReaderSettings;
setReaderSettings(newSettings);
// Stop any currently playing speech
Speech.stop();
// Update WebView settings
webViewRef.current?.injectJavaScript(
`reader.readerSettings.val = ${MMKVStorage.getString(
`
reader.readerSettings.val = ${MMKVStorage.getString(
CHAPTER_READER_SETTINGS,
)}`,
)};
// Auto-restart TTS if currently reading
if (window.tts && tts.reading) {
const currentElement = tts.currentElement;
const wasReading = tts.reading;
tts.stop();
if (wasReading) {
setTimeout(() => {
tts.start(currentElement);
}, 100);
}
}
`,
);
break;
case CHAPTER_GENERAL_SETTINGS:
@@ -180,6 +225,67 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
mmkvListener.remove();
};
}, [webViewRef]);
useEffect(() => {
const subscription = AppState.addEventListener('change', nextState => {
appStateRef.current = nextState;
if (nextState === 'active' && isTTSReadingRef.current) {
const index = ttsQueueIndexRef.current;
webViewRef.current?.injectJavaScript(`
if (window.tts && window.tts.allReadableElements) {
const idx = ${index};
if (idx < tts.allReadableElements.length) {
tts.elementsRead = idx;
tts.currentElement = tts.allReadableElements[idx];
tts.prevElement = null;
tts.started = true;
tts.reading = true;
tts.scrollToElement(tts.currentElement);
tts.currentElement.classList.add('highlight');
}
}
`);
}
});
return () => subscription.remove();
}, [webViewRef]);
const speakText = (text: string) => {
Speech.speak(text, {
onDone() {
const isBackground =
appStateRef.current === 'background' ||
appStateRef.current === 'inactive';
if (
isBackground &&
ttsQueueRef.current.length > 0 &&
ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length
) {
const nextIndex = ttsQueueIndexRef.current + 1;
const nextText = ttsQueueRef.current[nextIndex];
if (nextText) {
ttsQueueIndexRef.current = nextIndex;
speakText(nextText);
return;
}
}
if (isBackground) {
isTTSReadingRef.current = false;
dismissTTSNotification();
webViewRef.current?.injectJavaScript('tts.stop?.()');
return;
}
webViewRef.current?.injectJavaScript('tts.next?.()');
},
voice: readerSettingsRef.current.tts?.voice?.identifier,
pitch: readerSettingsRef.current.tts?.pitch || 1,
rate: readerSettingsRef.current.tts?.rate || 1,
});
};
return (
<WebView
ref={webViewRef}
@@ -191,6 +297,14 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
javaScriptEnabled={true}
webviewDebuggingEnabled={__DEV__}
onLoadEnd={() => {
// Update battery level when WebView finishes loading
const currentBatteryLevel = getBatteryLevelSync();
webViewRef.current?.injectJavaScript(
`if (window.reader && window.reader.batteryLevel) {
window.reader.batteryLevel.val = ${currentBatteryLevel};
}`,
);
if (autoStartTTSRef.current) {
autoStartTTSRef.current = false;
setTimeout(() => {
@@ -214,6 +328,24 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
__DEV__ && onLogMessage(ev);
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
switch (event.type) {
case 'tts-queue': {
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;
if (typeof payload?.startIndex === 'number') {
ttsQueueIndexRef.current = payload.startIndex;
} else {
ttsQueueIndexRef.current = 0;
}
break;
}
case 'hide':
onPress();
break;
@@ -234,6 +366,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
break;
case 'speak':
if (event.data && typeof event.data === 'string') {
if (typeof event.index === 'number') {
ttsQueueIndexRef.current = event.index;
}
if (!isTTSReadingRef.current) {
isTTSReadingRef.current = true;
showTTSNotification({
@@ -248,14 +383,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
isPlaying: true,
});
}
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,
});
speakText(event.data);
} else {
webViewRef.current?.injectJavaScript('tts.next?.()');
}
@@ -263,6 +391,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
case 'stop-speak':
Speech.stop();
isTTSReadingRef.current = false;
ttsQueueRef.current = [];
ttsQueueIndexRef.current = 0;
dismissTTSNotification();
break;
case 'tts-state':
@@ -20,6 +20,7 @@ export const sanitizeChapterText = (
'title',
'details',
'summary',
'ref',
]),
allowedAttributes: {
'*': ['data-*'],
@@ -29,6 +30,7 @@ export const sanitizeChapterText = (
ol: ['reversed', 'start', 'type'],
p: ['class', 'id'],
span: ['class', 'id'],
ref: ['href', 'class', 'id'],
},
allowedSchemes: ['data', 'http', 'https', 'file'],
});
@@ -68,7 +68,9 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
theme={theme}
/>
)}
keyExtractor={item => item.identifier || 'system'}
keyExtractor={(item, index) =>
item.identifier || `voice_${index}_${item.name}`
}
estimatedItemSize={64}
ListEmptyComponent={
<ActivityIndicator
@@ -1,20 +1,10 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import Slider from '@react-native-community/slider';
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
import {
useTheme,
useChapterGeneralSettings,
useChapterReaderSettings,
} from '@hooks/persisted';
import { useChapterGeneralSettings, useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { List, Button } from '@components/index';
import { List } from '@components/index';
import SettingSwitch from '../../components/SettingSwitch';
import Switch from '@components/Switch/Switch';
import { useBoolean } from '@hooks';
import { Portal } from 'react-native-paper';
import VoicePickerModal from '../Modals/VoicePickerModal';
const AccessibilityTab: React.FC = () => {
const theme = useTheme();
@@ -24,173 +14,71 @@ const AccessibilityTab: React.FC = () => {
showBatteryAndTime = false,
keepScreenOn = true,
bionicReading = false,
TTSEnable = true,
setChapterGeneralSettings,
} = useChapterGeneralSettings();
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
const [voices, setVoices] = useState<Voice[]>([]);
const {
value: voiceModalVisible,
setTrue: showVoiceModal,
setFalse: hideVoiceModal,
} = useBoolean();
useEffect(() => {
getAvailableVoicesAsync().then(res => {
res.sort((a, b) => a.name.localeCompare(b.name));
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
});
}, []);
return (
<>
<BottomSheetScrollView
style={styles.container}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.section}>
<List.SubHeader theme={theme}>
{getString('common.display')}
</List.SubHeader>
<SettingSwitch
label={getString('readerScreen.bottomSheet.fullscreen')}
value={fullScreenMode}
onPress={() =>
setChapterGeneralSettings({ fullScreenMode: !fullScreenMode })
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.showProgressPercentage')}
value={showScrollPercentage}
onPress={() =>
setChapterGeneralSettings({
showScrollPercentage: !showScrollPercentage,
})
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.showBatteryAndTime')}
value={showBatteryAndTime}
onPress={() =>
setChapterGeneralSettings({
showBatteryAndTime: !showBatteryAndTime,
})
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.keepScreenOn')}
value={keepScreenOn}
onPress={() =>
setChapterGeneralSettings({ keepScreenOn: !keepScreenOn })
}
theme={theme}
/>
</View>
<View style={styles.section}>
<List.SubHeader theme={theme}>Reading Enhancements</List.SubHeader>
<SettingSwitch
label={getString('readerScreen.bottomSheet.bionicReading')}
value={bionicReading}
onPress={() =>
setChapterGeneralSettings({ bionicReading: !bionicReading })
}
theme={theme}
/>
</View>
<View style={styles.section}>
<View style={styles.ttsHeader}>
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
<Switch
value={TTSEnable}
onValueChange={() => {
setChapterGeneralSettings({
TTSEnable: !TTSEnable,
});
}}
/>
</View>
{TTSEnable && (
<>
<List.Item
title="TTS voice"
description={tts?.voice?.name || 'System'}
onPress={showVoiceModal}
theme={theme}
/>
<View style={styles.sliderSection}>
<Text style={[styles.sliderLabel, { color: theme.onSurface }]}>
Voice rate: {tts?.rate?.toFixed(1) || '1.0'}
</Text>
<Slider
style={styles.slider}
value={tts?.rate || 1}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, rate: value } })
}
/>
</View>
<View style={styles.sliderSection}>
<Text style={[styles.sliderLabel, { color: theme.onSurface }]}>
Voice pitch: {tts?.pitch?.toFixed(1) || '1.0'}
</Text>
<Slider
style={styles.slider}
value={tts?.pitch || 1}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
}
/>
</View>
<View style={styles.resetButtonContainer}>
<Button
title={getString('common.reset')}
mode="outlined"
onPress={() => {
setChapterReaderSettings({
tts: {
pitch: 1,
rate: 1,
voice: { name: 'System', language: 'System' } as Voice,
},
});
}}
style={styles.resetButton}
/>
</View>
</>
)}
</View>
<View style={styles.bottomSpacing} />
</BottomSheetScrollView>
<Portal>
<VoicePickerModal
visible={voiceModalVisible}
onDismiss={hideVoiceModal}
voices={voices}
<BottomSheetScrollView
style={styles.container}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.section}>
<List.SubHeader theme={theme}>
{getString('common.display')}
</List.SubHeader>
<SettingSwitch
label={getString('readerScreen.bottomSheet.fullscreen')}
value={fullScreenMode}
onPress={() =>
setChapterGeneralSettings({ fullScreenMode: !fullScreenMode })
}
theme={theme}
/>
</Portal>
</>
<SettingSwitch
label={getString('readerScreen.bottomSheet.showProgressPercentage')}
value={showScrollPercentage}
onPress={() =>
setChapterGeneralSettings({
showScrollPercentage: !showScrollPercentage,
})
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.showBatteryAndTime')}
value={showBatteryAndTime}
onPress={() =>
setChapterGeneralSettings({
showBatteryAndTime: !showBatteryAndTime,
})
}
theme={theme}
/>
<SettingSwitch
label={getString('readerScreen.bottomSheet.keepScreenOn')}
value={keepScreenOn}
onPress={() =>
setChapterGeneralSettings({ keepScreenOn: !keepScreenOn })
}
theme={theme}
/>
</View>
<View style={styles.section}>
<List.SubHeader theme={theme}>Reading Enhancements</List.SubHeader>
<SettingSwitch
label={getString('readerScreen.bottomSheet.bionicReading')}
value={bionicReading}
onPress={() =>
setChapterGeneralSettings({ bionicReading: !bionicReading })
}
theme={theme}
/>
</View>
<View style={styles.bottomSpacing} />
</BottomSheetScrollView>
);
};
@@ -206,30 +94,6 @@ const styles = StyleSheet.create({
section: {
marginVertical: 8,
},
ttsHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingRight: 16,
},
sliderSection: {
paddingVertical: 12,
},
sliderLabel: {
fontSize: 16,
marginBottom: 8,
paddingHorizontal: 16,
},
slider: {
height: 40,
},
resetButtonContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
resetButton: {
alignSelf: 'flex-start',
},
bottomSpacing: {
height: 24,
},
+81 -27
View File
@@ -97,6 +97,9 @@ export default class ServiceManager {
}
isMultiplicableTask(task: BackgroundTask) {
if (!task?.name) {
return false;
}
return (
['DOWNLOAD_CHAPTER', 'IMPORT_EPUB', 'MIGRATE_NOVEL'] as Array<
BackgroundTask['name']
@@ -132,14 +135,18 @@ export default class ServiceManager {
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) {
const taskList = [...this.getTaskList()];
if (taskList.length === 0 || !taskList[0]?.meta) {
return;
}
taskList[0] = {
...taskList[0],
meta: transformer(taskList[0].meta),
};
if (
taskList[0].meta.isRunning &&
taskList[0].task.name !== 'DOWNLOAD_CHAPTER'
taskList[0].meta?.isRunning &&
taskList[0].task?.name !== 'DOWNLOAD_CHAPTER'
) {
const now = Date.now();
if (now - this.lastNotifUpdate > 1000) {
@@ -150,11 +157,11 @@ export default class ServiceManager {
return;
}
BackgroundService.updateNotification({
taskTitle: taskList[0].meta.name,
taskDesc: taskList[0].meta.progressText ?? '',
taskTitle: taskList[0].meta?.name || 'Unknown Task',
taskDesc: taskList[0].meta?.progressText ?? '',
progressBar: {
indeterminate: taskList[0].meta.progress === undefined,
value: (taskList[0].meta.progress || 0) * 100,
indeterminate: taskList[0].meta?.progress === undefined,
value: (taskList[0].meta?.progress || 0) * 100,
max: 100,
},
});
@@ -162,11 +169,11 @@ export default class ServiceManager {
} else {
this.lastNotifUpdate = now;
BackgroundService.updateNotification({
taskTitle: taskList[0].meta.name,
taskDesc: taskList[0].meta.progressText ?? '',
taskTitle: taskList[0].meta?.name || 'Unknown Task',
taskDesc: taskList[0].meta?.progressText ?? '',
progressBar: {
indeterminate: taskList[0].meta.progress === undefined,
value: (taskList[0].meta.progress || 0) * 100,
indeterminate: taskList[0].meta?.progress === undefined,
value: (taskList[0].meta?.progress || 0) * 100,
max: 100,
},
});
@@ -185,8 +192,8 @@ export default class ServiceManager {
let count = 0;
for (const task of startingTasks) {
if (
task.task.name === 'DOWNLOAD_CHAPTER' &&
task.meta.name === currentTask.meta.name
task.task?.name === 'DOWNLOAD_CHAPTER' &&
task.meta?.name === currentTask.meta?.name
) {
if (task.id === currentTask.id) {
i = count;
@@ -209,13 +216,18 @@ export default class ServiceManager {
task: QueuedBackgroundTask,
startingTasks: QueuedBackgroundTask[],
) {
// Safety check for old format tasks
if (!task?.task?.name) {
return;
}
const progress =
task.task.name === 'DOWNLOAD_CHAPTER'
? this.getProgressForNotification(task, startingTasks)
: null;
await BackgroundService.updateNotification({
taskTitle: task.meta.name,
taskDesc: task.meta.progressText ?? '',
taskTitle: task.meta?.name || 'Unknown Task',
taskDesc: task.meta?.progressText ?? '',
progressBar: {
indeterminate: progress === null,
max: 100,
@@ -279,12 +291,18 @@ export default class ServiceManager {
newtasks.forEach(t => tasksSet.add(t.id));
try {
// Safety check - getTaskList() should already handle conversion, but double-check
if (!currentTask?.task?.name) {
// Skip invalid tasks
setMMKVObject(manager.STORE_KEY, manager.getTaskList().slice(1));
continue;
}
await manager.executeTask(currentTask, startingTasks);
doneTasks[currentTask.task.name] += 1;
} catch (error: any) {
await Notifications.scheduleNotificationAsync({
content: {
title: currentTask.meta.name,
title: currentTask.meta?.name || 'Task Error',
body: error?.message || String(error),
},
trigger: null,
@@ -314,23 +332,26 @@ export default class ServiceManager {
}
getTaskName(task: BackgroundTask) {
if (!task?.name) {
return 'Unknown Task';
}
switch (task.name) {
case 'DOWNLOAD_CHAPTER':
return `${getString('notifications.DOWNLOAD_CHAPTER')}: ${
task.data.novelName
task.data?.novelName || ''
}`;
case 'IMPORT_EPUB':
return `${getString('notifications.IMPORT_EPUB')}: ${
task.data.filename
task.data?.filename || ''
}`;
case 'MIGRATE_NOVEL':
return `${getString('notifications.MIGRATE_NOVEL')}: ${
task.data.fromNovel.name
task.data?.fromNovel?.name || ''
}`;
case 'UPDATE_LIBRARY':
if (task.data !== undefined) {
return `${getString('notifications.UPDATE_LIBRARY')}: ${
task.data.categoryName
task.data?.categoryName || ''
}`;
}
return getString('notifications.UPDATE_LIBRARY');
@@ -352,18 +373,51 @@ export default class ServiceManager {
}
getTaskList() {
return getMMKVObject<Array<QueuedBackgroundTask>>(this.STORE_KEY) || [];
const tasks = getMMKVObject<Array<any>>(this.STORE_KEY) || [];
const convertedTasks = tasks
.map(task => {
if (task?.task && task?.meta && task?.id) {
return task as QueuedBackgroundTask;
}
if (task?.name && !task?.task) {
const backgroundTask = task as BackgroundTask;
return {
task: backgroundTask,
meta: {
name: this.getTaskName(backgroundTask),
isRunning: false,
progress: undefined,
progressText:
backgroundTask.name === 'DOWNLOAD_CHAPTER'
? (backgroundTask as DownloadChapterTask).data?.chapterName
: undefined,
},
id: makeId(),
} as QueuedBackgroundTask;
}
return null;
})
.filter((task): task is QueuedBackgroundTask => task !== null);
const hasOldFormat = tasks.some(task => task?.name && !task?.task);
if (hasOldFormat) {
setMMKVObject(this.STORE_KEY, convertedTasks);
}
return convertedTasks;
}
addTask(tasks: BackgroundTask | BackgroundTask[]) {
let currentTasks = this.getTaskList();
// @ts-expect-error Older version can still have tasks with old format
currentTasks = currentTasks.filter(task => !task?.name);
const currentTasks = this.getTaskList();
const addableTasks = (Array.isArray(tasks) ? tasks : [tasks]).filter(
task =>
this.isMultiplicableTask(task) ||
!currentTasks.some(_t => _t.task.name === task.name),
!currentTasks.some(_t => _t.task?.name === task.name),
);
if (addableTasks.length) {
const newTasks: QueuedBackgroundTask[] = addableTasks.map(task => ({
@@ -374,7 +428,7 @@ export default class ServiceManager {
progress: undefined,
progressText:
task.name === 'DOWNLOAD_CHAPTER'
? task.data.chapterName
? task.data?.chapterName
: undefined,
},
id: makeId(),
@@ -391,13 +445,13 @@ export default class ServiceManager {
this.pause();
setMMKVObject(
this.STORE_KEY,
taskList.filter(t => t.task.name !== name),
taskList.filter(t => t.task?.name !== name),
);
this.resume();
} else {
setMMKVObject(
this.STORE_KEY,
taskList.filter(t => t.task.name !== name),
taskList.filter(t => t.task?.name !== name),
);
}
}
+177 -57
View File
@@ -13,7 +13,7 @@ import {
getAllNovelCategories,
getCategoriesFromDb,
} from '@database/queries/CategoryQueries';
import { BackupCategory } from '@database/types';
import { BackupCategory, BackupNovel } from '@database/types';
import { BackupEntryName } from './types';
import { ROOT_STORAGE } from '@utils/Storages';
import ServiceManager from '@services/ServiceManager';
@@ -65,48 +65,82 @@ export const prepareBackupData = async (cacheDirPath: string) => {
NativeFile.mkdir(novelDirPath); // this also creates cacheDirPath
// version
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.VERSION,
JSON.stringify({ version: version }),
);
try {
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.VERSION,
JSON.stringify({ version: version }),
);
} catch (error: any) {
showToast(
getString('backupScreen.versionFileWriteFailed', {
error: error?.message || String(error),
}),
);
throw error;
}
// novels
await getAllNovels().then(async novels => {
for (const novel of novels) {
const chapters = await getNovelChapters(novel.id);
NativeFile.writeFile(
novelDirPath + '/' + novel.id + '.json',
JSON.stringify({
chapters: chapters,
...novel,
cover: novel.cover?.replace(APP_STORAGE_URI, ''),
}),
);
try {
const chapters = await getNovelChapters(novel.id);
NativeFile.writeFile(
novelDirPath + '/' + novel.id + '.json',
JSON.stringify({
chapters: chapters,
...novel,
cover: novel.cover?.replace(APP_STORAGE_URI, ''),
}),
);
} catch (error: any) {
showToast(
getString('backupScreen.novelBackupFailed', {
novelName: novel.name,
error: error?.message,
}),
);
}
}
});
// categories
const categories = getCategoriesFromDb();
const novelCategories = getAllNovelCategories();
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.CATEGORY,
JSON.stringify(
categories.map(category => {
return {
...category,
novelIds: novelCategories
.filter(nc => nc.categoryId === category.id)
.map(nc => nc.novelId),
};
try {
const categories = getCategoriesFromDb();
const novelCategories = getAllNovelCategories();
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.CATEGORY,
JSON.stringify(
categories.map(category => {
return {
...category,
novelIds: novelCategories
.filter(nc => nc.categoryId === category.id)
.map(nc => nc.novelId),
};
}),
),
);
} catch (error: any) {
showToast(
getString('backupScreen.categoryFileWriteFailed', {
error: error?.message || String(error),
}),
),
);
);
}
// settings
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.SETTING,
JSON.stringify(backupMMKVData()),
);
try {
NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.SETTING,
JSON.stringify(backupMMKVData()),
);
} catch (error: any) {
showToast(
getString('backupScreen.settingsFileWriteFailed', {
error: error?.message || String(error),
}),
);
}
};
export const restoreData = async (cacheDirPath: string) => {
@@ -117,40 +151,126 @@ export const restoreData = async (cacheDirPath: string) => {
// novels
showToast(getString('backupScreen.restoringNovels'));
const items = NativeFile.readDir(novelDirPath);
let novelCount = 0;
for (const item of items) {
if (!item.isDirectory) {
const backupNovel = JSON.parse(NativeFile.readFile(item.path));
if (!backupNovel.cover?.startsWith('http')) {
backupNovel.cover = APP_STORAGE_URI + backupNovel.cover;
let failedCount = 0;
if (!NativeFile.exists(novelDirPath)) {
showToast(getString('backupScreen.novelDirectoryNotFound'));
} else {
try {
const items = NativeFile.readDir(novelDirPath);
for (const item of items) {
if (!item.isDirectory) {
try {
const fileContent = NativeFile.readFile(item.path);
const backupNovel = JSON.parse(fileContent) as BackupNovel;
if (!backupNovel.cover?.startsWith('http')) {
backupNovel.cover = APP_STORAGE_URI + backupNovel.cover;
}
await _restoreNovelAndChapters(backupNovel);
novelCount++;
} catch (error: any) {
failedCount++;
const novelName =
item.path.split('/').pop()?.replace('.json', '') || 'Unknown';
showToast(
getString('backupScreen.novelRestoreFailed', {
novelName: novelName,
error: error?.message || String(error),
}),
);
}
}
}
await _restoreNovelAndChapters(backupNovel);
novelCount++;
} catch (error: any) {
showToast(
getString('backupScreen.novelDirectoryReadFailed', {
error: error?.message || String(error),
}),
);
}
}
showToast(getString('backupScreen.novelsRestored', { count: novelCount }));
if (failedCount > 0) {
showToast(
getString('backupScreen.novelsRestoredWithErrors', {
count: novelCount,
failedCount: failedCount,
}),
);
} else {
showToast(getString('backupScreen.novelsRestored', { count: novelCount }));
}
// categories
showToast(getString('backupScreen.restoringCategories'));
const categories: BackupCategory[] = JSON.parse(
NativeFile.readFile(cacheDirPath + '/' + BackupEntryName.CATEGORY),
);
for (const category of categories) {
await _restoreCategory(category);
const categoryFilePath = cacheDirPath + '/' + BackupEntryName.CATEGORY;
let categoryCount = 0;
let failedCategoryCount = 0;
if (!NativeFile.exists(categoryFilePath)) {
showToast(getString('backupScreen.categoryFileNotFound'));
} else {
try {
const fileContent = NativeFile.readFile(categoryFilePath);
const categories: BackupCategory[] = JSON.parse(fileContent);
for (const category of categories) {
try {
_restoreCategory(category);
categoryCount++;
} catch (error: any) {
failedCategoryCount++;
showToast(
getString('backupScreen.categoryRestoreFailed', {
categoryName: category.name || category.id.toString(),
error: error?.message || String(error),
}),
);
}
}
} catch (error: any) {
showToast(
getString('backupScreen.categoryFileReadFailed', {
error: error?.message || String(error),
}),
);
}
}
if (failedCategoryCount > 0) {
showToast(
getString('backupScreen.categoriesRestoredWithErrors', {
count: categoryCount,
failedCount: failedCategoryCount,
}),
);
} else {
showToast(
getString('backupScreen.categoriesRestored', {
count: categoryCount,
}),
);
}
showToast(
getString('backupScreen.categoriesRestored', {
count: categories.length,
}),
);
// settings
showToast(getString('backupScreen.restoringSettings'));
restoreMMKVData(
JSON.parse(
NativeFile.readFile(cacheDirPath + '/' + BackupEntryName.SETTING),
),
);
showToast(getString('backupScreen.settingsRestored'));
const settingsFilePath = cacheDirPath + '/' + BackupEntryName.SETTING;
if (!NativeFile.exists(settingsFilePath)) {
showToast(getString('backupScreen.settingsFileNotFound'));
} else {
try {
const fileContent = NativeFile.readFile(settingsFilePath);
const settingsData = JSON.parse(fileContent);
restoreMMKVData(settingsData);
showToast(getString('backupScreen.settingsRestored'));
} catch (error: any) {
showToast(
getString('backupScreen.settingsRestoreFailed', {
error: error?.message || String(error),
}),
);
}
}
};
+8 -3
View File
@@ -12,7 +12,7 @@ export const fetchNovel = async (pluginId: string, novelPath: string) => {
export const fetchChapter = async (pluginId: string, chapterPath: string) => {
const plugin = getPlugin(pluginId);
let chapterText = `Unkown plugin: ${pluginId}`;
let chapterText = `Unknown plugin: ${pluginId}`;
if (plugin) {
chapterText = await plugin.parseChapter(chapterPath);
}
@@ -34,8 +34,13 @@ export const fetchPage = async (
page: string,
) => {
const plugin = getPlugin(pluginId);
if (!plugin || !plugin.parsePage) {
throw new Error('Cant parse page!');
if (!plugin) {
throw new Error(`Unknown plugin: ${pluginId}`);
}
if (!plugin.parsePage) {
throw new Error(`Could not fetch chapters for page ${page}`);
}
const res = await plugin.parsePage(novelPath, page);
return res;
+2 -2
View File
@@ -49,7 +49,7 @@ export const showTTSNotification = async (data: TTSNotificationData) => {
sound: false,
priority: Notifications.AndroidNotificationPriority.HIGH,
},
trigger: null,
trigger: { seconds: 1, channelId: 'tts-controls' },
});
};
@@ -66,7 +66,7 @@ export const updateTTSNotification = async (data: TTSNotificationData) => {
sound: false,
priority: Notifications.AndroidNotificationPriority.HIGH,
},
trigger: null,
trigger: { seconds: 1, channelId: 'tts-controls' },
});
};
+14
View File
@@ -76,6 +76,11 @@
"backupRestored": "Backup restored successfully",
"savingBackup": "Saving Backup",
"categoriesRestored": "Restored %{count} categories",
"categoriesRestoredWithErrors": "Restored %{count} categories (%{failedCount} failed)",
"categoryRestoreFailed": "Failed to restore category: %{categoryName} - %{error}",
"categoryFileNotFound": "Category file not found in backup",
"categoryFileReadFailed": "Failed to read category file: %{error}",
"categoryFileWriteFailed": "Failed to write category file: %{error}",
"createBackup": "Create backup",
"createBackupDesc": "Can be used to restore current library",
"createBackupWarning": "Create backup may not work on devices with Android 9 or lower.",
@@ -83,10 +88,19 @@
"downloadingDownloadedFiles": "Downloading Downloaded Files",
"failed": "Backup failed",
"novelsRestored": "Restored %{count} novels",
"novelsRestoredWithErrors": "Restored %{count} novels (%{failedCount} failed)",
"novelBackupFailed": "Failed to backup novel: %{novelName} - %{error}",
"novelRestoreFailed": "Failed to restore novel: %{novelName} - %{error}",
"novelDirectoryNotFound": "Novel directory not found in backup",
"novelDirectoryReadFailed": "Failed to read novel directory: %{error}",
"restoringCategories": "Restoring Categories",
"restoringNovels": "Restoring Novels",
"restoringSettings": "Restoring Settings",
"settingsRestored": "Settings restored",
"settingsFileNotFound": "Settings file not found in backup",
"settingsRestoreFailed": "Failed to restore settings: %{error}",
"settingsFileWriteFailed": "Failed to write settings file: %{error}",
"versionFileWriteFailed": "Failed to write version file: %{error}",
"drive": {
"backup": "Drive Backup",
"backupInterruped": "Drive Backup Interrupted",
+14
View File
@@ -69,6 +69,11 @@ export interface StringMap {
'backupScreen.backupRestored': 'string';
'backupScreen.savingBackup': 'string';
'backupScreen.categoriesRestored': 'string';
'backupScreen.categoriesRestoredWithErrors': 'string';
'backupScreen.categoryRestoreFailed': 'string';
'backupScreen.categoryFileNotFound': 'string';
'backupScreen.categoryFileReadFailed': 'string';
'backupScreen.categoryFileWriteFailed': 'string';
'backupScreen.createBackup': 'string';
'backupScreen.createBackupDesc': 'string';
'backupScreen.createBackupWarning': 'string';
@@ -76,10 +81,19 @@ export interface StringMap {
'backupScreen.downloadingDownloadedFiles': 'string';
'backupScreen.failed': 'string';
'backupScreen.novelsRestored': 'string';
'backupScreen.novelsRestoredWithErrors': 'string';
'backupScreen.novelBackupFailed': 'string';
'backupScreen.novelRestoreFailed': 'string';
'backupScreen.novelDirectoryNotFound': 'string';
'backupScreen.novelDirectoryReadFailed': 'string';
'backupScreen.restoringCategories': 'string';
'backupScreen.restoringNovels': 'string';
'backupScreen.restoringSettings': 'string';
'backupScreen.settingsRestored': 'string';
'backupScreen.settingsFileNotFound': 'string';
'backupScreen.settingsRestoreFailed': 'string';
'backupScreen.settingsFileWriteFailed': 'string';
'backupScreen.versionFileWriteFailed': 'string';
'backupScreen.drive.backup': 'string';
'backupScreen.drive.backupInterruped': 'string';
'backupScreen.drive.googleDriveBackup': 'string';