Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e478b2cc8 |
@@ -45,7 +45,7 @@ body:
|
||||
label: LNReader version
|
||||
description: You can find your LNReader version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "2.0.2"
|
||||
Example: "2.0.1"
|
||||
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 plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
|
||||
- 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.2](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.0.1](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -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.2](https://github.com/LNReader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.0.1](https://github.com/LNReader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -88,5 +88,3 @@ flake.lock
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
.cursor/
|
||||
@@ -75,7 +75,7 @@ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
ext {
|
||||
versionMajor = 2
|
||||
versionMinor = 0
|
||||
versionPatch = 2
|
||||
versionPatch = 0
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -140,10 +140,6 @@ 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;
|
||||
@@ -173,41 +169,22 @@ window.tts = new (function () {
|
||||
};
|
||||
|
||||
// if can find a readable node, else stop tts
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.findNextTextNode = () => {
|
||||
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;
|
||||
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;
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
} else {
|
||||
// can read? read it
|
||||
if (this.readable()) {
|
||||
@@ -220,117 +197,50 @@ 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;
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else if (
|
||||
this.currentElement.parentElement &&
|
||||
!this.currentElement.parentElement.isSameNode(document.body) &&
|
||||
!this.currentElement.parentElement.isSameNode(document.documentElement)
|
||||
) {
|
||||
} else {
|
||||
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');
|
||||
|
||||
// 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 (this.findNextTextNode()) {
|
||||
const text = this.normalizeText(this.currentElement?.innerText);
|
||||
if (text) {
|
||||
// Found valid text - speak it
|
||||
this.currentElement = nextElement;
|
||||
this.reading = true;
|
||||
this.elementsRead++;
|
||||
this.speak();
|
||||
return;
|
||||
} else {
|
||||
// Empty text, skip to next in array (no recursion!)
|
||||
this.elementsRead++;
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.stop();
|
||||
alert('TTS Error: ' + e.message);
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = element => {
|
||||
this.stop();
|
||||
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.currentElement = element ?? reader.chapterElement;
|
||||
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 (
|
||||
@@ -358,16 +268,7 @@ 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 => {
|
||||
@@ -386,7 +287,6 @@ 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)
|
||||
@@ -401,27 +301,11 @@ window.tts = new (function () {
|
||||
|
||||
// Only scroll if element is not visible or barely visible
|
||||
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -432,11 +316,7 @@ window.tts = new (function () {
|
||||
this.currentElement.classList.add('highlight');
|
||||
const text = this.normalizeText(this.currentElement.innerText);
|
||||
if (text) {
|
||||
reader.post({
|
||||
type: 'speak',
|
||||
data: text,
|
||||
index: this.elementsRead - 1,
|
||||
});
|
||||
reader.post({ type: 'speak', data: text });
|
||||
reader.post({ type: 'tts-state', data: { isReading: true } });
|
||||
} else {
|
||||
this.next();
|
||||
@@ -444,15 +324,6 @@ 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);
|
||||
|
||||
@@ -264,10 +264,6 @@ 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');
|
||||
@@ -307,8 +303,7 @@ const TTSController = () => {
|
||||
top = reader.layoutHeight - 120;
|
||||
}
|
||||
controllerElement.style.top = `${top}px`;
|
||||
// Check if TTS is still enabled before starting
|
||||
if (hoverElement && reader.generalSettings.val.TTSEnable) {
|
||||
if (hoverElement) {
|
||||
tts.start(hoverElement);
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
@@ -318,18 +313,11 @@ 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 if (tts.started) {
|
||||
tts.resume();
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
} else {
|
||||
tts.start();
|
||||
tts.resume();
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
},
|
||||
|
||||
+41
-41
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lnreader",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.1",
|
||||
"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.16",
|
||||
"@react-native-community/slider": "^5.1.1",
|
||||
"@legendapp/list": "^2.0.14",
|
||||
"@react-native-community/slider": "^5.0.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.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",
|
||||
"@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",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"color": "^5.0.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "^54.0.25",
|
||||
"color": "^5.0.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "^54.0.2",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-file-system": "~19.0.19",
|
||||
"expo-file-system": "~19.0.12",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-keep-awake": "~15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.9",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.9",
|
||||
"expo-notifications": "~0.32.13",
|
||||
"expo-navigation-bar": "~5.0.8",
|
||||
"expo-notifications": "~0.32.11",
|
||||
"expo-speech": "~14.0.7",
|
||||
"expo-sqlite": "~16.0.9",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"expo-sqlite": "~16.0.8",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"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.5",
|
||||
"react-native": "^0.81.4",
|
||||
"react-native-background-actions": "^4.0.1",
|
||||
"react-native-device-info": "^14.1.1",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-drawer-layout": "^4.2.0",
|
||||
"react-native-drawer-layout": "^4.1.13",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-error-boundary": "^2.0.0",
|
||||
"react-native-error-boundary": "^2.0.1",
|
||||
"react-native-file-access": "^3.2.0",
|
||||
"react-native-gesture-handler": "^2.29.1",
|
||||
"react-native-gesture-handler": "^2.28.0",
|
||||
"react-native-lottie-splash-screen": "^1.1.2",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-mmkv": "^3.3.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.1.5",
|
||||
"react-native-reanimated": "^4.1.0",
|
||||
"react-native-saf-x": "^2.2.3",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.18.0",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-screens": "^4.16.0",
|
||||
"react-native-shimmer-placeholder": "^2.0.9",
|
||||
"react-native-tab-view": "^4.2.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-tab-view": "^4.1.3",
|
||||
"react-native-url-polyfill": "^2.0.1",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "^0.6.1",
|
||||
"react-native-worklets": "^0.5.1",
|
||||
"react-native-zip-archive": "^7.0.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"urlencode": "^2.0.0"
|
||||
"urlencode": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@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.5",
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
"@react-native/metro-config": "^0.81.5",
|
||||
"@react-native/typescript-config": "^0.81.5",
|
||||
"@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",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "~19.1.17",
|
||||
"@types/react": "~19.1.12",
|
||||
"@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.3"
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
Generated
+1095
-1097
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
/* 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',
|
||||
},
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
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;
|
||||
@@ -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 '@components';
|
||||
import { Menu } from 'react-native-paper';
|
||||
import { MaterialDesignIconName } from '@type/icon';
|
||||
|
||||
export interface RightIcon {
|
||||
interface RightIcon {
|
||||
iconName: MaterialDesignIconName;
|
||||
color?: string;
|
||||
onPress: () => void;
|
||||
@@ -112,6 +112,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
<Menu
|
||||
visible={extraMenu}
|
||||
onDismiss={() => showExtraMenu(false)}
|
||||
anchorPosition="bottom"
|
||||
anchor={
|
||||
<IconButtonV2
|
||||
name="dots-vertical"
|
||||
@@ -134,9 +135,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
}}
|
||||
onPress={() => {
|
||||
showExtraMenu(false);
|
||||
setTimeout(() => {
|
||||
button.onPress();
|
||||
}, 0);
|
||||
button.onPress();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -18,5 +18,3 @@ 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';
|
||||
|
||||
+5
-19
@@ -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 2
|
||||
* Sets up all tables, indexes, triggers and sets version to 1
|
||||
*/
|
||||
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 = 2');
|
||||
db.execSync('PRAGMA user_version = 1');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -65,23 +65,9 @@ export const initializeDatabase = () => {
|
||||
db.execSync('PRAGMA cache_size = 10000');
|
||||
db.execSync('PRAGMA foreign_keys = ON');
|
||||
|
||||
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;
|
||||
}
|
||||
const userVersion =
|
||||
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version ?? 0;
|
||||
|
||||
if (userVersion === 0) {
|
||||
createInitialSchema();
|
||||
|
||||
@@ -7,14 +7,10 @@ const columnExists = (
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean => {
|
||||
try {
|
||||
const columns = db.getAllSync<{ name: string }>(
|
||||
`PRAGMA table_info(${tableName})`,
|
||||
);
|
||||
return columns.some(col => col.name === columnName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const columns = db.getAllSync<{ name: string }>(
|
||||
`PRAGMA table_info(${tableName})`,
|
||||
);
|
||||
return columns.some(col => col.name === columnName);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,99 +23,86 @@ export const migration002: Migration = {
|
||||
version: 2,
|
||||
description: 'Add counter columns and triggers to Novel table',
|
||||
migrate: db => {
|
||||
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', 'chaptersDownloaded')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN chaptersDownloaded INTEGER DEFAULT 0
|
||||
`);
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -375,7 +375,7 @@ export const getUpdatedOverviewFromDb = () =>
|
||||
Novel.name AS novelName,
|
||||
Novel.cover AS novelCover,
|
||||
Novel.path AS novelPath,
|
||||
DATE(Chapter.updatedTime) AS updateDate,
|
||||
DATE(Chapter.updatedTime) AS updateDate, -- Extract the date from updatedTime
|
||||
COUNT(*) AS updatesPerDay
|
||||
FROM
|
||||
Chapter
|
||||
@@ -387,10 +387,14 @@ WHERE
|
||||
Chapter.updatedTime IS NOT NULL
|
||||
GROUP BY
|
||||
Novel.id,
|
||||
DATE(Chapter.updatedTime)
|
||||
Novel.name,
|
||||
Novel.cover,
|
||||
Novel.path,
|
||||
DATE(Chapter.updatedTime) -- Group by date and novelId
|
||||
ORDER BY
|
||||
updateDate DESC,
|
||||
novelId;
|
||||
novelId,
|
||||
updateDate;
|
||||
|
||||
`);
|
||||
|
||||
export const getDetailedUpdatesFromDb = async (
|
||||
|
||||
@@ -321,12 +321,10 @@ export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
|
||||
Object.values(novel) as string[] | number[],
|
||||
],
|
||||
]);
|
||||
if (chapters.length > 0) {
|
||||
await runAsync(
|
||||
chapters.map(chapter => [
|
||||
restoreObjectQuery('Chapter', chapter),
|
||||
Object.values(chapter) as string[] | number[],
|
||||
]),
|
||||
);
|
||||
}
|
||||
runAsync(
|
||||
chapters.map(chapter => [
|
||||
restoreObjectQuery('Chapter', chapter),
|
||||
Object.values(chapter) as string[] | number[],
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,19 +49,10 @@ export class MigrationRunner {
|
||||
}
|
||||
|
||||
private getCurrentVersion(db: SQLiteDatabase): number {
|
||||
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;
|
||||
}
|
||||
return (
|
||||
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
private setVersion(db: SQLiteDatabase, version: number): void {
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
@@ -38,23 +40,12 @@ 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 || undefined,
|
||||
downloadUrl: data.assets[0].browser_download_url,
|
||||
};
|
||||
|
||||
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
|
||||
@@ -62,7 +53,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
setLatestRelease(release);
|
||||
setChecking(false);
|
||||
} catch (error) {
|
||||
// Silently fail in offline mode or on network errors
|
||||
showToast(`Failed to check for updates: ${getErrorMessage(error)}`);
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
@@ -80,15 +71,17 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
checkForRelease();
|
||||
}, []);
|
||||
|
||||
if (!checking && latestRelease?.tag_name) {
|
||||
return {
|
||||
if (!checking && shouldCheckForUpdate()) {
|
||||
const data = {
|
||||
latestRelease,
|
||||
isNewVersion: isNewVersion(latestRelease.tag_name),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
latestRelease: undefined,
|
||||
isNewVersion: false,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
return {
|
||||
latestRelease: undefined,
|
||||
isNewVersion: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,8 +110,6 @@ export interface ChapterReaderSettings {
|
||||
voice?: Voice;
|
||||
rate?: number;
|
||||
pitch?: number;
|
||||
autoPageAdvance?: boolean;
|
||||
scrollToTop?: boolean;
|
||||
};
|
||||
epubLocation: string;
|
||||
epubUseAppTheme: boolean;
|
||||
@@ -182,7 +180,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
removeExtraParagraphSpacing: false,
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: true,
|
||||
TTSEnable: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
@@ -199,8 +197,6 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
tts: {
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
autoPageAdvance: false,
|
||||
scrollToTop: true,
|
||||
},
|
||||
epubLocation: '',
|
||||
epubUseAppTheme: false,
|
||||
@@ -272,23 +268,9 @@ export const useChapterGeneralSettings = () => {
|
||||
};
|
||||
|
||||
export const useChapterReaderSettings = () => {
|
||||
const [storedSettings = initialChapterReaderSettings, setSettings] =
|
||||
const [chapterReaderSettings = 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 });
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs';
|
||||
|
||||
import Library from '../screens/library/LibraryScreen';
|
||||
import Updates from '../screens/updates/UpdatesScreen';
|
||||
@@ -12,7 +15,8 @@ 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 { BottomTabBar } from '@components';
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import { BottomNavigation } from 'react-native-paper';
|
||||
|
||||
const Tab = createBottomTabNavigator<BottomNavigatorParamList>();
|
||||
|
||||
@@ -59,6 +63,55 @@ 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={() => ({
|
||||
@@ -70,14 +123,7 @@ const BottomNavigator = () => {
|
||||
color: theme.onError,
|
||||
},
|
||||
})}
|
||||
tabBar={props => (
|
||||
<BottomTabBar
|
||||
{...props}
|
||||
theme={theme}
|
||||
showLabelsInNav={showLabelsInNav}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
)}
|
||||
tabBar={renderBottomBar}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Library"
|
||||
|
||||
@@ -53,15 +53,11 @@ const CategoriesScreen = () => {
|
||||
|
||||
const systemCategories = categories.filter(cat => cat.id === 1);
|
||||
|
||||
const updatedOrderCategories = [...systemCategories, ...data].map(
|
||||
(category, index) => ({
|
||||
...category,
|
||||
sort: index,
|
||||
}),
|
||||
);
|
||||
|
||||
setCategories(updatedOrderCategories);
|
||||
updateCategoryOrderInDb(updatedOrderCategories);
|
||||
const updatedOrderCategories = [...systemCategories, ...data];
|
||||
requestAnimationFrame(() => {
|
||||
setCategories(updatedOrderCategories);
|
||||
updateCategoryOrderInDb(updatedOrderCategories);
|
||||
});
|
||||
};
|
||||
|
||||
const renderItem = ({
|
||||
|
||||
@@ -53,7 +53,6 @@ const FirstRoute = () => {
|
||||
estimatedItemSize={4}
|
||||
extraData={[filter]}
|
||||
data={libraryFilterList}
|
||||
keyExtractor={item => `filter_${item.filter}`}
|
||||
renderItem={({ item }) => (
|
||||
<Checkbox
|
||||
label={item.label}
|
||||
@@ -86,7 +85,6 @@ const SecondRoute = () => {
|
||||
data={librarySortOrderList}
|
||||
extraData={[sortOrder]}
|
||||
estimatedItemSize={5}
|
||||
keyExtractor={(item, index) => `sort_${index}_${item.ASC}`}
|
||||
renderItem={({ item }) => (
|
||||
<SortItem
|
||||
label={item.label}
|
||||
@@ -163,7 +161,6 @@ const ThirdRoute = () => {
|
||||
estimatedItemSize={4}
|
||||
data={displayModesList}
|
||||
extraData={[displayMode]}
|
||||
keyExtractor={item => `display_mode_${item.value}`}
|
||||
renderItem={({ item }) => (
|
||||
<RadioButton
|
||||
label={item.label}
|
||||
|
||||
@@ -9,10 +9,7 @@ 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,
|
||||
QueuedBackgroundTask,
|
||||
} from '@services/ServiceManager';
|
||||
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
|
||||
// type Library = Category & { novels: LibraryNovelInfo[] };
|
||||
@@ -109,24 +106,17 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
getLibrary();
|
||||
});
|
||||
|
||||
const [taskQueue] = useMMKVObject<
|
||||
Array<BackgroundTask | QueuedBackgroundTask>
|
||||
>(ServiceManager.manager.STORE_KEY);
|
||||
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
|
||||
ServiceManager.manager.STORE_KEY,
|
||||
);
|
||||
const restoreTasksCount = useMemo(
|
||||
() =>
|
||||
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?.filter(
|
||||
t =>
|
||||
t.name === 'LOCAL_RESTORE' ||
|
||||
t.name === 'DRIVE_RESTORE' ||
|
||||
t.name === 'SELF_HOST_RESTORE',
|
||||
).length || 0,
|
||||
[taskQueue],
|
||||
);
|
||||
const prevRestoreTasksCountRef = useRef(restoreTasksCount);
|
||||
|
||||
@@ -59,8 +59,8 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
<List.Divider theme={theme} />
|
||||
<List.Item
|
||||
title={getString('aboutScreen.website')}
|
||||
description="https://lnreader.app"
|
||||
onPress={() => Linking.openURL('https://lnreader.app')}
|
||||
description="https://lnreader.github.io"
|
||||
onPress={() => Linking.openURL('https://lnreader.github.io')}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
|
||||
@@ -318,6 +318,7 @@ 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, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { 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,14 +30,16 @@ 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('');
|
||||
@@ -46,30 +48,6 @@ 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('');
|
||||
@@ -89,21 +67,20 @@ const JumpToChapterModal = ({
|
||||
|
||||
const scrollToChapter = (chap: ChapterInfo) => {
|
||||
onDismiss();
|
||||
const index = allChapters.findIndex(c => c.id === chap.id);
|
||||
chapterListRef.current?.scrollToItem({
|
||||
animated: true,
|
||||
item: chap,
|
||||
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 scrollToIndex = (index: number) => {
|
||||
onDismiss();
|
||||
chapterListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: index,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
};
|
||||
|
||||
const executeFunction = (item: ChapterInfo) => {
|
||||
@@ -141,15 +118,13 @@ const JumpToChapterModal = ({
|
||||
const num = Number(text);
|
||||
if (num && num >= minNumber && num <= maxNumber) {
|
||||
if (openChapter) {
|
||||
const chapter = allChapters.find(c => c.chapterNumber === num);
|
||||
const chapter = chapters.find(c => c.chapterNumber === num);
|
||||
if (chapter) {
|
||||
return navigateToChapter(chapter);
|
||||
}
|
||||
} else {
|
||||
const chapter = allChapters.find(c => c.chapterNumber === num);
|
||||
if (chapter) {
|
||||
return scrollToChapter(chapter);
|
||||
}
|
||||
const index = chapters.findIndex(c => c.chapterNumber === num);
|
||||
return scrollToIndex(index);
|
||||
}
|
||||
}
|
||||
return setError(
|
||||
@@ -157,7 +132,7 @@ const JumpToChapterModal = ({
|
||||
` (${num < minNumber ? '≥ ' + minNumber : '≤ ' + maxNumber})`,
|
||||
);
|
||||
} else {
|
||||
const searchedChapters = allChapters.filter(chap =>
|
||||
const searchedChapters = chapters.filter(chap =>
|
||||
chap.name.toLowerCase().includes(text?.toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -251,7 +226,6 @@ const JumpToChapterModal = ({
|
||||
data={result}
|
||||
extraData={openChapter}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => `chapter_${item.id}`}
|
||||
contentContainerStyle={styles.listContentCtn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar } from 'react-native-paper';
|
||||
import { Menu as DefaultMenu } from '@components';
|
||||
import { Appbar, Menu as DefaultMenu } from 'react-native-paper';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -36,7 +35,7 @@ const Menu = React.memo(
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
anchor={anchor}
|
||||
// anchorPosition="bottom"
|
||||
anchorPosition="bottom"
|
||||
contentStyle={{ backgroundColor: theme.surface2 }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
|
||||
@@ -63,7 +63,6 @@ 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
|
||||
pages={pages}
|
||||
currentPageIndex={pageIndex}
|
||||
onPageChange={openPage}
|
||||
currentPage={pageIndex + 1}
|
||||
totalPages={pages.length}
|
||||
onPageChange={pageNum => openPage(pageNum - 1)}
|
||||
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
@@ -95,7 +95,6 @@ export default function PageNavigationBottomSheet({
|
||||
recycleItems
|
||||
extraData={pageIndex}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `page_${index}_${item}`}
|
||||
estimatedItemSize={56}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
@@ -7,74 +7,72 @@ import { Row } from '@components/Common';
|
||||
import { borderColor } from '@theme/colors';
|
||||
|
||||
interface PagePaginationControlProps {
|
||||
pages: string[];
|
||||
currentPageIndex: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onOpenDrawer: () => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
pages,
|
||||
currentPageIndex,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onOpenDrawer,
|
||||
theme,
|
||||
}) => {
|
||||
const totalPages = pages.length;
|
||||
|
||||
const pageIndices = useMemo(() => {
|
||||
const indices: (number | 'ellipsis')[] = [];
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
|
||||
if (totalPages <= 3) {
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
indices.push(i);
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPageIndex !== 0) {
|
||||
indices.push(0);
|
||||
if (currentPage !== 1) {
|
||||
pages.push(1);
|
||||
}
|
||||
|
||||
const leftPageIndex = currentPageIndex - 1;
|
||||
if (leftPageIndex > 0) {
|
||||
if (leftPageIndex > 1) {
|
||||
indices.push('ellipsis');
|
||||
const leftPage = currentPage - 1;
|
||||
if (leftPage > 1) {
|
||||
if (leftPage > 2) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
indices.push(leftPageIndex);
|
||||
pages.push(leftPage);
|
||||
}
|
||||
|
||||
indices.push(currentPageIndex);
|
||||
pages.push(currentPage);
|
||||
|
||||
if (currentPageIndex < totalPages - 1) {
|
||||
indices.push('ellipsis');
|
||||
if (currentPage < totalPages - 1) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
if (currentPageIndex !== totalPages - 1) {
|
||||
indices.push(totalPages - 1);
|
||||
if (totalPages !== currentPage) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return indices;
|
||||
}, [currentPageIndex, totalPages]);
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const canGoPrevious = currentPageIndex > 0;
|
||||
const canGoNext = currentPageIndex < totalPages - 1;
|
||||
const canGoPrevious = currentPage > 1;
|
||||
const canGoNext = currentPage < totalPages;
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (canGoPrevious) {
|
||||
onPageChange(currentPageIndex - 1);
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (canGoNext) {
|
||||
onPageChange(currentPageIndex + 1);
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePagePress = (pageIndex: number) => {
|
||||
if (pageIndex !== currentPageIndex) {
|
||||
onPageChange(pageIndex);
|
||||
const handlePagePress = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,8 +101,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
</Pressable>
|
||||
|
||||
<Row style={styles.pageNumbersRow}>
|
||||
{pageIndices.map((pageIndex, index) => {
|
||||
if (pageIndex === 'ellipsis') {
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === 'ellipsis') {
|
||||
return (
|
||||
<Pressable
|
||||
key={`ellipsis-${index}`}
|
||||
@@ -126,11 +124,10 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = pageIndex === currentPageIndex;
|
||||
const pageName = pages[pageIndex];
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<Pressable
|
||||
key={`page-${pageIndex}`}
|
||||
key={page}
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
@@ -138,7 +135,7 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
backgroundColor: isActive ? theme.primary : theme.surface,
|
||||
},
|
||||
]}
|
||||
onPress={() => handlePagePress(pageIndex)}
|
||||
onPress={() => handlePagePress(page)}
|
||||
android_ripple={{
|
||||
color: isActive
|
||||
? color(theme.onPrimary).alpha(0.2).string()
|
||||
@@ -153,9 +150,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{pageName}
|
||||
{page}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -230,7 +226,6 @@ const styles = StyleSheet.create({
|
||||
pageText: {
|
||||
fontSize: 15,
|
||||
letterSpacing: 0.15,
|
||||
maxWidth: 100,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -159,9 +159,7 @@ const ChapterDrawer = () => {
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
extraData={[chapter, scrollToIndex.current]}
|
||||
keyExtractor={item =>
|
||||
`chapter_${item.id}_${item.position ?? 'no_pos'}`
|
||||
}
|
||||
keyExtractor={item => (item.position ?? item.id).toString()}
|
||||
renderItem={val =>
|
||||
renderListChapter({
|
||||
item: val.item,
|
||||
|
||||
@@ -27,7 +27,6 @@ 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';
|
||||
@@ -131,7 +130,6 @@ interface ReaderBottomSheetV2Props {
|
||||
const routes = [
|
||||
{ key: 'readerTab', title: getString('readerSettings.title') },
|
||||
{ key: 'generalTab', title: getString('generalSettings') },
|
||||
{ key: 'ttsTab', title: 'TTS' },
|
||||
];
|
||||
|
||||
const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
|
||||
@@ -145,7 +143,7 @@ const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
|
||||
const backgroundColor = tabHeaderColor;
|
||||
|
||||
const renderScene = useMemo(
|
||||
() => SceneMap({ readerTab: ReaderTab, generalTab: GeneralTab, ttsTab: TTSTab }),
|
||||
() => SceneMap({ readerTab: ReaderTab, generalTab: GeneralTab }),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,5 @@
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AppState,
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import React, { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native';
|
||||
import WebView from 'react-native-webview';
|
||||
import color from 'color';
|
||||
|
||||
@@ -35,9 +30,8 @@ import {
|
||||
|
||||
type WebViewPostEvent = {
|
||||
type: string;
|
||||
data?: { [key: string]: unknown };
|
||||
data?: { [key: string]: string | number };
|
||||
autoStartTTS?: boolean;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
type WebViewReaderProps = {
|
||||
@@ -73,11 +67,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
webViewRef,
|
||||
} = useChapterContext();
|
||||
const theme = useTheme();
|
||||
// Use state for settings so they update when MMKV changes
|
||||
const [readerSettings, setReaderSettings] = useState<ChapterReaderSettings>(
|
||||
const readerSettings = useMemo(
|
||||
() =>
|
||||
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(
|
||||
() =>
|
||||
@@ -87,31 +83,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chapter.id],
|
||||
);
|
||||
|
||||
// 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 batteryLevel = useMemo(() => getBatteryLevelSync(), []);
|
||||
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(() => {
|
||||
@@ -173,33 +151,10 @@ 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:
|
||||
@@ -225,67 +180,6 @@ 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}
|
||||
@@ -297,14 +191,6 @@ 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(() => {
|
||||
@@ -328,24 +214,6 @@ 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;
|
||||
@@ -366,9 +234,6 @@ 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({
|
||||
@@ -383,7 +248,14 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
isPlaying: true,
|
||||
});
|
||||
}
|
||||
speakText(event.data);
|
||||
Speech.speak(event.data, {
|
||||
onDone() {
|
||||
webViewRef.current?.injectJavaScript('tts.next?.()');
|
||||
},
|
||||
voice: readerSettings.tts?.voice?.identifier,
|
||||
pitch: readerSettings.tts?.pitch || 1,
|
||||
rate: readerSettings.tts?.rate || 1,
|
||||
});
|
||||
} else {
|
||||
webViewRef.current?.injectJavaScript('tts.next?.()');
|
||||
}
|
||||
@@ -391,8 +263,6 @@ 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,7 +20,6 @@ export const sanitizeChapterText = (
|
||||
'title',
|
||||
'details',
|
||||
'summary',
|
||||
'ref',
|
||||
]),
|
||||
allowedAttributes: {
|
||||
'*': ['data-*'],
|
||||
@@ -30,7 +29,6 @@ export const sanitizeChapterText = (
|
||||
ol: ['reversed', 'start', 'type'],
|
||||
p: ['class', 'id'],
|
||||
span: ['class', 'id'],
|
||||
ref: ['href', 'class', 'id'],
|
||||
},
|
||||
allowedSchemes: ['data', 'http', 'https', 'file'],
|
||||
});
|
||||
|
||||
@@ -68,9 +68,7 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item, index) =>
|
||||
item.identifier || `voice_${index}_${item.name}`
|
||||
}
|
||||
keyExtractor={item => item.identifier || 'system'}
|
||||
estimatedItemSize={64}
|
||||
ListEmptyComponent={
|
||||
<ActivityIndicator
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import { useChapterGeneralSettings, useTheme } from '@hooks/persisted';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
|
||||
import {
|
||||
useTheme,
|
||||
useChapterGeneralSettings,
|
||||
useChapterReaderSettings,
|
||||
} from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import { List } from '@components/index';
|
||||
import { List, Button } 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();
|
||||
@@ -14,71 +24,173 @@ 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>
|
||||
<>
|
||||
<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}>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,6 +206,30 @@ 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,
|
||||
},
|
||||
|
||||
@@ -97,9 +97,6 @@ export default class ServiceManager {
|
||||
}
|
||||
|
||||
isMultiplicableTask(task: BackgroundTask) {
|
||||
if (!task?.name) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
['DOWNLOAD_CHAPTER', 'IMPORT_EPUB', 'MIGRATE_NOVEL'] as Array<
|
||||
BackgroundTask['name']
|
||||
@@ -135,18 +132,14 @@ 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) {
|
||||
@@ -157,11 +150,11 @@ export default class ServiceManager {
|
||||
return;
|
||||
}
|
||||
BackgroundService.updateNotification({
|
||||
taskTitle: taskList[0].meta?.name || 'Unknown Task',
|
||||
taskDesc: taskList[0].meta?.progressText ?? '',
|
||||
taskTitle: taskList[0].meta.name,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -169,11 +162,11 @@ export default class ServiceManager {
|
||||
} else {
|
||||
this.lastNotifUpdate = now;
|
||||
BackgroundService.updateNotification({
|
||||
taskTitle: taskList[0].meta?.name || 'Unknown Task',
|
||||
taskDesc: taskList[0].meta?.progressText ?? '',
|
||||
taskTitle: taskList[0].meta.name,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -192,8 +185,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;
|
||||
@@ -216,18 +209,13 @@ 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 || 'Unknown Task',
|
||||
taskDesc: task.meta?.progressText ?? '',
|
||||
taskTitle: task.meta.name,
|
||||
taskDesc: task.meta.progressText ?? '',
|
||||
progressBar: {
|
||||
indeterminate: progress === null,
|
||||
max: 100,
|
||||
@@ -291,18 +279,12 @@ 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 || 'Task Error',
|
||||
title: currentTask.meta.name,
|
||||
body: error?.message || String(error),
|
||||
},
|
||||
trigger: null,
|
||||
@@ -332,26 +314,23 @@ 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');
|
||||
@@ -373,51 +352,18 @@ export default class ServiceManager {
|
||||
}
|
||||
|
||||
getTaskList() {
|
||||
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;
|
||||
return getMMKVObject<Array<QueuedBackgroundTask>>(this.STORE_KEY) || [];
|
||||
}
|
||||
|
||||
addTask(tasks: BackgroundTask | BackgroundTask[]) {
|
||||
const currentTasks = this.getTaskList();
|
||||
let currentTasks = this.getTaskList();
|
||||
// @ts-expect-error Older version can still have tasks with old format
|
||||
currentTasks = currentTasks.filter(task => !task?.name);
|
||||
|
||||
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 => ({
|
||||
@@ -428,7 +374,7 @@ export default class ServiceManager {
|
||||
progress: undefined,
|
||||
progressText:
|
||||
task.name === 'DOWNLOAD_CHAPTER'
|
||||
? task.data?.chapterName
|
||||
? task.data.chapterName
|
||||
: undefined,
|
||||
},
|
||||
id: makeId(),
|
||||
@@ -445,13 +391,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-177
@@ -13,7 +13,7 @@ import {
|
||||
getAllNovelCategories,
|
||||
getCategoriesFromDb,
|
||||
} from '@database/queries/CategoryQueries';
|
||||
import { BackupCategory, BackupNovel } from '@database/types';
|
||||
import { BackupCategory } from '@database/types';
|
||||
import { BackupEntryName } from './types';
|
||||
import { ROOT_STORAGE } from '@utils/Storages';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
@@ -65,82 +65,48 @@ export const prepareBackupData = async (cacheDirPath: string) => {
|
||||
NativeFile.mkdir(novelDirPath); // this also creates cacheDirPath
|
||||
|
||||
// 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;
|
||||
}
|
||||
NativeFile.writeFile(
|
||||
cacheDirPath + '/' + BackupEntryName.VERSION,
|
||||
JSON.stringify({ version: version }),
|
||||
);
|
||||
|
||||
// novels
|
||||
await getAllNovels().then(async novels => {
|
||||
for (const novel of novels) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const chapters = await getNovelChapters(novel.id);
|
||||
NativeFile.writeFile(
|
||||
novelDirPath + '/' + novel.id + '.json',
|
||||
JSON.stringify({
|
||||
chapters: chapters,
|
||||
...novel,
|
||||
cover: novel.cover?.replace(APP_STORAGE_URI, ''),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// categories
|
||||
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),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
// settings
|
||||
try {
|
||||
NativeFile.writeFile(
|
||||
cacheDirPath + '/' + BackupEntryName.SETTING,
|
||||
JSON.stringify(backupMMKVData()),
|
||||
);
|
||||
} catch (error: any) {
|
||||
showToast(
|
||||
getString('backupScreen.settingsFileWriteFailed', {
|
||||
error: error?.message || String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
NativeFile.writeFile(
|
||||
cacheDirPath + '/' + BackupEntryName.SETTING,
|
||||
JSON.stringify(backupMMKVData()),
|
||||
);
|
||||
};
|
||||
|
||||
export const restoreData = async (cacheDirPath: string) => {
|
||||
@@ -151,126 +117,40 @@ export const restoreData = async (cacheDirPath: string) => {
|
||||
|
||||
// novels
|
||||
showToast(getString('backupScreen.restoringNovels'));
|
||||
const items = NativeFile.readDir(novelDirPath);
|
||||
let novelCount = 0;
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(
|
||||
getString('backupScreen.novelDirectoryReadFailed', {
|
||||
error: error?.message || String(error),
|
||||
}),
|
||||
);
|
||||
await _restoreNovelAndChapters(backupNovel);
|
||||
novelCount++;
|
||||
}
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
showToast(
|
||||
getString('backupScreen.novelsRestoredWithErrors', {
|
||||
count: novelCount,
|
||||
failedCount: failedCount,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showToast(getString('backupScreen.novelsRestored', { count: novelCount }));
|
||||
}
|
||||
showToast(getString('backupScreen.novelsRestored', { count: novelCount }));
|
||||
|
||||
// categories
|
||||
showToast(getString('backupScreen.restoringCategories'));
|
||||
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,
|
||||
}),
|
||||
);
|
||||
const categories: BackupCategory[] = JSON.parse(
|
||||
NativeFile.readFile(cacheDirPath + '/' + BackupEntryName.CATEGORY),
|
||||
);
|
||||
for (const category of categories) {
|
||||
await _restoreCategory(category);
|
||||
}
|
||||
showToast(
|
||||
getString('backupScreen.categoriesRestored', {
|
||||
count: categories.length,
|
||||
}),
|
||||
);
|
||||
|
||||
// settings
|
||||
showToast(getString('backupScreen.restoringSettings'));
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
restoreMMKVData(
|
||||
JSON.parse(
|
||||
NativeFile.readFile(cacheDirPath + '/' + BackupEntryName.SETTING),
|
||||
),
|
||||
);
|
||||
showToast(getString('backupScreen.settingsRestored'));
|
||||
};
|
||||
|
||||
@@ -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 = `Unknown plugin: ${pluginId}`;
|
||||
let chapterText = `Unkown plugin: ${pluginId}`;
|
||||
if (plugin) {
|
||||
chapterText = await plugin.parseChapter(chapterPath);
|
||||
}
|
||||
@@ -34,13 +34,8 @@ export const fetchPage = async (
|
||||
page: string,
|
||||
) => {
|
||||
const plugin = getPlugin(pluginId);
|
||||
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin: ${pluginId}`);
|
||||
}
|
||||
|
||||
if (!plugin.parsePage) {
|
||||
throw new Error(`Could not fetch chapters for page ${page}`);
|
||||
if (!plugin || !plugin.parsePage) {
|
||||
throw new Error('Cant parse page!');
|
||||
}
|
||||
const res = await plugin.parsePage(novelPath, page);
|
||||
return res;
|
||||
|
||||
@@ -49,7 +49,7 @@ export const showTTSNotification = async (data: TTSNotificationData) => {
|
||||
sound: false,
|
||||
priority: Notifications.AndroidNotificationPriority.HIGH,
|
||||
},
|
||||
trigger: { seconds: 1, channelId: 'tts-controls' },
|
||||
trigger: null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export const updateTTSNotification = async (data: TTSNotificationData) => {
|
||||
sound: false,
|
||||
priority: Notifications.AndroidNotificationPriority.HIGH,
|
||||
},
|
||||
trigger: { seconds: 1, channelId: 'tts-controls' },
|
||||
trigger: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -76,11 +76,6 @@
|
||||
"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.",
|
||||
@@ -88,19 +83,10 @@
|
||||
"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",
|
||||
|
||||
@@ -69,11 +69,6 @@ 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';
|
||||
@@ -81,19 +76,10 @@ 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';
|
||||
|
||||
Reference in New Issue
Block a user