Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb3875fe2 | |||
| 2c9df1d93f | |||
| 5db79a09eb | |||
| ff771fdd68 | |||
| 169c4b9de1 | |||
| 4bcd22b9de | |||
| 8a2dc15da6 | |||
| 3f3a407820 | |||
| c72808d72f | |||
| dabe4710bb | |||
| cdc4ddcc97 | |||
| 8f783fd4c2 | |||
| 048119e530 | |||
| b68d44d500 | |||
| 4bc3638b38 | |||
| bc5e2f1fd1 | |||
| e8e7519c40 | |||
| 5460047e41 | |||
| bdd00cd87d | |||
| f83c017e09 | |||
| e38cb9972f | |||
| ac9c789a89 | |||
| 445187cf11 | |||
| 52ec11a33d | |||
| c498d7e9cd | |||
| 0fad9b907f | |||
| 0a170f14bc | |||
| 8245e77672 | |||
| 9c68260592 | |||
| b1778c5802 | |||
| be4ed6de79 | |||
| 3597b542d3 | |||
| fd6d912391 | |||
| d9dc18da43 | |||
| a22d754f95 | |||
| d9d7d3aaf0 | |||
| a399927264 | |||
| b4b00a5c66 | |||
| 9861f81589 | |||
| d06aecb5f8 | |||
| 86c82c1a61 | |||
| 1131446ddc | |||
| f6b2516643 | |||
| 7cf8692e77 | |||
| d6290294f7 | |||
| d5cb0a234f | |||
| 306d8d1d42 | |||
| 7b1eb7dd9d | |||
| 1ad2c98473 | |||
| f0b8df93b4 | |||
| 19ce7da32e | |||
| 9d35f6d303 | |||
| 31d0aa1787 | |||
| ea5fde4156 | |||
| 03e3816ab8 | |||
| 9d6e407c40 | |||
| 6781b5d6a4 | |||
| 352913f00e | |||
| 73f7ebf7b5 | |||
| 045d6419d8 | |||
| acc9d8e7ed | |||
| a29cce9eba | |||
| 1292f520bb | |||
| 8bd6131862 | |||
| ed6c2b7ba2 | |||
| f68a7a4e6a | |||
| c9d2ce382f |
+2
-1
@@ -1,2 +1,3 @@
|
||||
**/node_modules
|
||||
android
|
||||
android/**/js/van.js
|
||||
android/**/js/text-vibe.js
|
||||
|
||||
@@ -7,4 +7,5 @@ module.exports = {
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
quoteProps: 'preserve',
|
||||
endOfLine: 'auto', // stop prettier from getting mad on windows
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { enableFreeze } from 'react-native-screens';
|
||||
|
||||
enableFreeze(true);
|
||||
|
||||
@@ -98,6 +98,8 @@ android {
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
applicationIdSuffix 'debug'
|
||||
versionNameSuffix "-debug"
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">LNReader debug</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,6 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
padding-top: var(--StatusBar-currentHeight);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -16,9 +15,10 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-left: var(--readerSettings-padding);
|
||||
padding-right: var(--readerSettings-padding);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-bottom: 40px;
|
||||
padding-top: var(--StatusBar-currentHeight);
|
||||
|
||||
font-size: var(--readerSettings-textSize);
|
||||
background-color: var(--readerSettings-theme);
|
||||
@@ -28,8 +28,21 @@ body {
|
||||
font-family: var(--readerSettings-fontFamily);
|
||||
}
|
||||
|
||||
chapter {
|
||||
display: block;
|
||||
body.page-reader {
|
||||
overflow: hidden;
|
||||
padding-bottom: unset;
|
||||
}
|
||||
|
||||
#LNReader-chapter {
|
||||
padding-left: var(--readerSettings-padding);
|
||||
padding-right: var(--readerSettings-padding);
|
||||
}
|
||||
|
||||
body.page-reader > #LNReader-chapter{
|
||||
height: calc(90vh);
|
||||
column-width: calc(100vw - var(--readerSettings-padding) * 2);
|
||||
column-gap: calc(var(--readerSettings-padding) * 2);
|
||||
transition: 200ms;
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -73,9 +86,10 @@ td {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.nextButton,
|
||||
.infoText {
|
||||
width: 100%;
|
||||
.next-button,
|
||||
.info-text {
|
||||
margin-left: var(--readerSettings-padding);
|
||||
margin-right: var(--readerSettings-padding);
|
||||
border-radius: 50px;
|
||||
border-width: 1;
|
||||
color: var(--theme-onPrimary);
|
||||
@@ -86,15 +100,16 @@ td {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nextButton {
|
||||
.next-button {
|
||||
min-height: 40px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
width: calc(100% - var(--readerSettings-padding) * 2);
|
||||
}
|
||||
|
||||
.infoText {
|
||||
.info-text {
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
border: none;
|
||||
@@ -104,11 +119,6 @@ td {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.chapterCtn {
|
||||
min-height: var(--chapterCtn-height);
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none;
|
||||
}
|
||||
@@ -148,32 +158,15 @@ td {
|
||||
right: unset;
|
||||
}
|
||||
|
||||
#TTS-Controller {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #TTS-Controller {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
#TTS-Controller button {
|
||||
background: var(--theme-surface-0-9);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#TTS-Controller svg {
|
||||
fill: var(--theme-onSurface);
|
||||
}
|
||||
|
||||
#ToolWrapper > #ScrollBar {
|
||||
margin-top: 8px;
|
||||
width: 2.4rem;
|
||||
height: 45vh;
|
||||
height: 50vh;
|
||||
min-height: 200px;
|
||||
border-radius: 1.2rem;
|
||||
background-color: var(--theme-surface-0-9);
|
||||
touch-action: none;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -226,7 +219,7 @@ td {
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar {
|
||||
display: flex;
|
||||
width: 80vw;
|
||||
width: 95.5vw;
|
||||
height: 2.4rem;
|
||||
min-height: unset;
|
||||
align-items: center;
|
||||
@@ -300,123 +293,20 @@ td {
|
||||
.reader-footer-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reader-footer-item:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reader-footer-item:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--theme-onSecondary);
|
||||
.highlight, .highlight *{
|
||||
color: var(--theme-onSecondary) !important;
|
||||
background-color: var(--theme-secondary);
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--theme-surface-0-9);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
backdrop-filter: blur(1px);
|
||||
position: fixed;
|
||||
top: var(--top);
|
||||
left: var(--left);
|
||||
-webkit-animation: menuAnimation 0.4s 0s both;
|
||||
animation: menuAnimation 0.4s 0s both;
|
||||
transform-origin: left;
|
||||
list-style: none;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999999999;
|
||||
}
|
||||
|
||||
.contextMenu-item {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.contextMenu-button {
|
||||
color: var(--theme-onSurface);
|
||||
background: 0;
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
padding: 6px 24px 6px 7px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
-webkit-animation: menuItemAnimation 0.2s 0s both;
|
||||
animation: menuItemAnimation 0.2s 0s both;
|
||||
font-family: var(--readerSettings-fontFamily);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: var(--theme-rippleColor);
|
||||
}
|
||||
|
||||
.contextMenu-button svg {
|
||||
fill: var(--theme-onSurface);
|
||||
}
|
||||
.contextMenu-button span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes menuAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes menuItemAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuItemAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
#Image-Modal {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -438,3 +328,41 @@ td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#TTS-Controller {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#TTS-Controller button {
|
||||
background: var(--theme-surface-0-9);
|
||||
outline: none;
|
||||
border-width: 0;
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border-radius: 100%;
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
#TTS-Controller.active{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#TTS-Controller.active button {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#TTS-Controller svg {
|
||||
fill: var(--theme-onSurface);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#TTS-Controller.active svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
window.reader = new (function () {
|
||||
const {
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval,
|
||||
DEBUG,
|
||||
strings,
|
||||
} = initialReaderConfig;
|
||||
|
||||
// state
|
||||
this.hidden = van.state(true);
|
||||
this.batteryLevel = van.state(batteryLevel);
|
||||
this.readerSettings = van.state(readerSettings);
|
||||
this.generalSettings = van.state(chapterGeneralSettings);
|
||||
|
||||
this.chapterElement = document.querySelector('#LNReader-chapter');
|
||||
this.selection = window.getSelection();
|
||||
this.viewport = document.querySelector('meta[name=viewport]');
|
||||
|
||||
this.novel = novel;
|
||||
this.chapter = chapter;
|
||||
this.nextChapter = nextChapter;
|
||||
this.strings = strings;
|
||||
this.autoSaveInterval = autoSaveInterval;
|
||||
this.rawHTML = this.chapterElement.innerHTML;
|
||||
|
||||
//layout props
|
||||
this.paddingTop = parseInt(
|
||||
getComputedStyle(document.querySelector('body')).getPropertyValue(
|
||||
'padding-top',
|
||||
),
|
||||
10,
|
||||
);
|
||||
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
||||
this.layoutHeight = window.screen.height;
|
||||
this.layoutWidth = window.screen.width;
|
||||
|
||||
this.post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
||||
this.refresh = () => {
|
||||
if (this.generalSettings.val.pageReader) {
|
||||
this.chapterWidth = this.chapterElement.scrollWidth;
|
||||
} else {
|
||||
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
||||
}
|
||||
};
|
||||
|
||||
van.derive(() => {
|
||||
const settings = this.readerSettings.val;
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-theme',
|
||||
settings.theme,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-padding',
|
||||
settings.padding + 'px',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textSize',
|
||||
settings.textSize + 'px',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textColor',
|
||||
settings.textColor,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textAlign',
|
||||
settings.textAlign,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-lineHeight',
|
||||
settings.lineHeight,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-fontFamily',
|
||||
settings.fontFamily,
|
||||
);
|
||||
if (settings.fontFamily) {
|
||||
new FontFace(
|
||||
settings.fontFamily,
|
||||
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
||||
)
|
||||
.load()
|
||||
.then(function (loadedFont) {
|
||||
document.fonts.add(loadedFont);
|
||||
});
|
||||
} else {
|
||||
// have no affect with a font declared in head
|
||||
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (!this.generalSettings.val.pageReader) {
|
||||
this.post({
|
||||
type: 'save',
|
||||
data: parseInt(
|
||||
((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100,
|
||||
10,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.post({
|
||||
type: 'save',
|
||||
data: parseInt(
|
||||
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
|
||||
),
|
||||
});
|
||||
}
|
||||
}, this.autoSaveInterval);
|
||||
|
||||
if (DEBUG) {
|
||||
console = new Object();
|
||||
console.log = function (...data) {
|
||||
reader.post({ 'type': 'console', 'msg': data?.join(' ') });
|
||||
};
|
||||
console.debug = console.log;
|
||||
console.info = console.log;
|
||||
console.warn = console.log;
|
||||
console.error = console.log;
|
||||
}
|
||||
// end reader
|
||||
})();
|
||||
|
||||
window.tts = new (function () {
|
||||
this.readableNodeNames = [
|
||||
'#text',
|
||||
'B',
|
||||
'I',
|
||||
'SPAN',
|
||||
'EM',
|
||||
'BR',
|
||||
'STRONG',
|
||||
'A',
|
||||
];
|
||||
this.prevElement = null;
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
|
||||
this.readable = element => {
|
||||
ele = element ?? this.currentElement;
|
||||
if (
|
||||
ele.nodeName !== 'SPAN' &&
|
||||
this.readableNodeNames.includes(ele.nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!ele.hasChildNodes()) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < ele.childNodes.length; i++) {
|
||||
if (!this.readableNodeNames.includes(ele.childNodes.item(i).nodeName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// if can find a readable node, else stop tts
|
||||
this.findNextTextNode = () => {
|
||||
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
|
||||
return false;
|
||||
} else {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
// is read, have to go next or go back
|
||||
if (this.currentElement.isSameNode(this.prevElement)) {
|
||||
this.prevElement = this.currentElement;
|
||||
if (this.currentElement.nextElementSibling) {
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
} else {
|
||||
// can read? read it
|
||||
if (this.readable()) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!this.prevElement?.parentElement?.isSameNode(this.currentElement) &&
|
||||
this.currentElement.firstElementChild
|
||||
) {
|
||||
// go deep
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.firstElementChild;
|
||||
} else if (this.currentElement.nextElementSibling) {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
}
|
||||
};
|
||||
|
||||
this.next = () => {
|
||||
try {
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
if (this.findNextTextNode()) {
|
||||
this.reading = true;
|
||||
this.speak();
|
||||
} else {
|
||||
this.reading = false;
|
||||
this.stop();
|
||||
document.getElementById('TTS-Controller').firstElementChild.innerHTML =
|
||||
volumnIcon;
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = element => {
|
||||
this.stop();
|
||||
this.currentElement = element ?? reader.chapterElement;
|
||||
this.next();
|
||||
};
|
||||
|
||||
this.resume = () => {
|
||||
if (!this.reading) {
|
||||
if (
|
||||
this.currentElement &&
|
||||
this.currentElement.id !== 'LNReader-chapter'
|
||||
) {
|
||||
this.speak();
|
||||
this.reading = true;
|
||||
} else {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.pause = () => {
|
||||
this.reading = false;
|
||||
reader.post({ type: 'stop-speak' });
|
||||
};
|
||||
|
||||
this.stop = () => {
|
||||
reader.post({ type: 'stop-speak' });
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
this.prevElement = null;
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
};
|
||||
|
||||
this.speak = () => {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement.classList.add('highlight');
|
||||
reader.post({ type: 'speak', data: this.currentElement?.innerText });
|
||||
};
|
||||
})();
|
||||
|
||||
window.pageReader = new (function () {
|
||||
this.page = van.state(0);
|
||||
this.totalPages = van.state(0);
|
||||
|
||||
this.movePage = destPage => {
|
||||
destPage = parseInt(destPage);
|
||||
if (destPage < 0) {
|
||||
return reader.post({ type: 'prev' });
|
||||
}
|
||||
if (destPage >= this.totalPages.val) {
|
||||
return reader.post({ type: 'next' });
|
||||
}
|
||||
this.page.val = destPage;
|
||||
reader.chapterElement.style.transform =
|
||||
'translateX(-' + destPage * 100 + '%)';
|
||||
};
|
||||
|
||||
van.derive(() => {
|
||||
// ignore if initial or other states change
|
||||
if (
|
||||
reader.generalSettings.val.pageReader ===
|
||||
reader.generalSettings.oldVal.pageReader
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const ratio = Math.min(
|
||||
0.99,
|
||||
(window.scrollY + reader.layoutHeight) / reader.chapterHeight,
|
||||
);
|
||||
document.body.classList.add('page-reader');
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
this.totalPages.val = parseInt(
|
||||
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
|
||||
reader.layoutWidth,
|
||||
);
|
||||
this.movePage(this.totalPages.val * ratio);
|
||||
}, 100);
|
||||
} else {
|
||||
reader.chapterElement.style = '';
|
||||
document.body.classList.remove('page-reader');
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
window.scrollTo({
|
||||
top:
|
||||
(reader.chapterHeight * (this.page.val + 1)) / this.totalPages.val -
|
||||
reader.layoutHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// wait for content is rendered
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
pageReader.totalPages.val = parseInt(
|
||||
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
|
||||
reader.layoutWidth,
|
||||
);
|
||||
pageReader.movePage(
|
||||
parseInt(
|
||||
pageReader.totalPages.val *
|
||||
Math.min(0.99, reader.chapter.progress / 100),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top:
|
||||
(reader.chapterHeight * reader.chapter.progress) / 100 -
|
||||
reader.layoutHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// click handler
|
||||
(function () {
|
||||
const detectTapPosition = (x, y, horizontal) => {
|
||||
if (horizontal) {
|
||||
if (x < 0.33) {
|
||||
return 'left';
|
||||
}
|
||||
if (x > 0.66) {
|
||||
return 'right';
|
||||
}
|
||||
} else {
|
||||
if (y < 0.33) {
|
||||
return 'top';
|
||||
}
|
||||
if (y > 0.66) {
|
||||
return 'bottom';
|
||||
}
|
||||
}
|
||||
return 'center';
|
||||
};
|
||||
document.onclick = e => {
|
||||
const { clientX, clientY } = e;
|
||||
const { x, y } = {
|
||||
x: clientX / reader.layoutWidth,
|
||||
y: clientY / reader.layoutHeight,
|
||||
};
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const position = detectTapPosition(x, y, true);
|
||||
if (position === 'left') {
|
||||
pageReader.movePage(pageReader.page.val - 1);
|
||||
return;
|
||||
}
|
||||
if (position === 'right') {
|
||||
pageReader.movePage(pageReader.page.val + 1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (reader.generalSettings.val.tapToScroll) {
|
||||
const position = detectTapPosition(x, y, false);
|
||||
if (position === 'top') {
|
||||
window.scrollBy({
|
||||
top: -reader.layoutHeight * 0.75,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (position === 'bottom') {
|
||||
window.scrollBy({
|
||||
top: reader.layoutHeight * 0.75,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.post({ type: 'hide' });
|
||||
};
|
||||
})();
|
||||
|
||||
// swipe handler
|
||||
(function () {
|
||||
this.initialX = null;
|
||||
this.initialY = null;
|
||||
|
||||
reader.chapterElement.addEventListener('touchstart', e => {
|
||||
this.initialX = e.changedTouches[0].screenX;
|
||||
this.initialY = e.changedTouches[0].screenY;
|
||||
});
|
||||
|
||||
reader.chapterElement.addEventListener('touchmove', e => {
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const diffX =
|
||||
(e.changedTouches[0].screenX - this.initialX) / reader.layoutWidth;
|
||||
reader.chapterElement.style.transition = 'unset';
|
||||
reader.chapterElement.style.transform =
|
||||
'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)';
|
||||
}
|
||||
});
|
||||
|
||||
reader.chapterElement.addEventListener('touchend', e => {
|
||||
const diffX = e.changedTouches[0].screenX - this.initialX;
|
||||
const diffY = e.changedTouches[0].screenY - this.initialY;
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
reader.chapterElement.style.transition = '200ms';
|
||||
const diffXPercentage = diffX / reader.layoutWidth;
|
||||
if (diffXPercentage < -0.3) {
|
||||
pageReader.movePage(pageReader.page.val + 1);
|
||||
} else if (diffXPercentage > 0.3) {
|
||||
pageReader.movePage(pageReader.page.val - 1);
|
||||
} else {
|
||||
pageReader.movePage(pageReader.page.val);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.target.id?.startsWith('scrollbar') ||
|
||||
e.target.id === 'Image-Modal-img'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
reader.generalSettings.val.swipeGestures &&
|
||||
Math.abs(diffX) > Math.abs(diffY) * 2 &&
|
||||
Math.abs(diffX) > 180
|
||||
) {
|
||||
if (diffX < 0 && this.initialX >= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'next' });
|
||||
} else if (diffX > 0 && this.initialX <= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'prev' });
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// text options
|
||||
(function () {
|
||||
van.derive(() => {
|
||||
let html = reader.rawHTML;
|
||||
if (reader.generalSettings.val.bionicReading) {
|
||||
html = textVide.textVide(reader.rawHTML);
|
||||
}
|
||||
|
||||
if (reader.generalSettings.val.removeExtraParagraphSpacing) {
|
||||
html = html.replace(/<\s*br[^>]*>/gi, '\n').replace(/\n{2,}/g, '\n\n');
|
||||
}
|
||||
reader.chapterElement.innerHTML = html;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
const volumnIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M552-152v-75q86-23 139-93.5T744-480q0-89-53.5-158.5T552-734v-75q116 25 190 117t74 211q0 119-73.5 211.5T552-152ZM144-385v-192h144l192-192v576L288-385H144Zm408 55v-302q45 20 70.5 61t25.5 90q0 49-25.5 89.5T552-330Z"/></svg>';
|
||||
const pauseIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M564-228v-504h168v504H564Zm-336 0v-504h168v504H228Z"/></svg>';
|
||||
const resumeIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M240-240v-480h72v480h-72Zm144 0 384-240-384-240v480Z"/></svg>';
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import {
|
||||
ChapterGeneralSettings,
|
||||
ChapterReaderSettings,
|
||||
} from '@hooks/persisted/useSettings';
|
||||
import { State } from './van';
|
||||
|
||||
export interface Reader {
|
||||
// element
|
||||
chapterElement: HTMLElement;
|
||||
viewport: HTMLMetaElement;
|
||||
selection: Selection;
|
||||
|
||||
// state
|
||||
hidden: State<boolean>;
|
||||
generalSettings: State<ChapterGeneralSettings>;
|
||||
readerSettings: State<ChapterReaderSettings>;
|
||||
batteryLevel: State<number>;
|
||||
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
nextChapter?: ChapterInfo;
|
||||
autoSaveInterval: number;
|
||||
rawHTML: string;
|
||||
strings: {
|
||||
finished: string;
|
||||
nextChapter: string;
|
||||
noNextChapter: string;
|
||||
};
|
||||
|
||||
//layout props
|
||||
paddingTop: number;
|
||||
layoutHeight: number;
|
||||
layoutWidth: number;
|
||||
chapterHeight: number;
|
||||
chapterWidth: number;
|
||||
|
||||
post: (obj: Record<string, any>) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface PageReader {
|
||||
page: State<number>;
|
||||
totalPages: State<number>;
|
||||
movePage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface TTS {
|
||||
started: boolean;
|
||||
reading: boolean;
|
||||
start: (element?: HTMLElement) => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
readable: (element?: HTMLElement) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
const reader: Reader;
|
||||
const tts: TTS;
|
||||
const pageReader: PageReader;
|
||||
}
|
||||
@@ -1,643 +1,336 @@
|
||||
/**
|
||||
* https://fonts.google.com/icons
|
||||
* Filled
|
||||
* other settings are default
|
||||
*/
|
||||
const copyIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Z"/></svg>';
|
||||
const volumeIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320Z"/></svg>';
|
||||
const selectAllIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M280-280v-400h400v400H280Zm80-80h240v-240H360v240ZM200-200v80q-33 0-56.5-23.5T120-200h80Zm-80-80v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm80-160h-80q0-33 23.5-56.5T200-840v80Zm80 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80q0 33-23.5 56.5T760-120Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80q33 0 56.5 23.5T840-760h-80Z"/></svg>';
|
||||
const translateIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>';
|
||||
const resumeIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M240-240v-480h80v480h-80Zm160 0 400-240-400-240v480Z"/></svg>';
|
||||
const pauseIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg>';
|
||||
const stopIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M320-320h320v-320H320v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>';
|
||||
const selectVolumeIcon =
|
||||
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131Zm-80-29L280-360H120v-240h160l200-200v640Zm80-160v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM40-680v-240h240v80H120v160H40ZM680-40v-80h160v-160h80v240H680Z"/></svg>';
|
||||
/**
|
||||
* @type {import("./type").Reader}
|
||||
*/
|
||||
class Reader {
|
||||
constructor() {
|
||||
this.selection = window.getSelection();
|
||||
this.viewport = document.querySelector('meta[name=viewport]');
|
||||
this.footerWrapper = document.getElementById('reader-footer-wrapper');
|
||||
this.percentage = document.getElementById('reader-percentage');
|
||||
this.battery = document.getElementById('reader-battery');
|
||||
this.time = document.getElementById('reader-time');
|
||||
this.paddingTop = parseInt(
|
||||
getComputedStyle(document.querySelector('html')).getPropertyValue(
|
||||
'padding-top',
|
||||
),
|
||||
);
|
||||
this.chapter = document.querySelector('chapter');
|
||||
this.rawHTML = this.chapter.innerHTML;
|
||||
this.chapterHeight = this.chapter.scrollHeight + this.paddingTop;
|
||||
this.layoutHeight = window.innerHeight;
|
||||
this.pluginId = this.chapter.getAttribute('data-plugin-id');
|
||||
this.novelId = this.chapter.getAttribute('data-novel-id');
|
||||
this.chapterId = this.chapter.getAttribute('data-chapter-id');
|
||||
this.saveProgressInterval = setInterval(
|
||||
() =>
|
||||
this.post({
|
||||
type: 'save',
|
||||
data: parseInt(
|
||||
((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100,
|
||||
),
|
||||
}),
|
||||
autoSaveInterval,
|
||||
);
|
||||
this.time.innerText = new Date().toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
this.timeInterval = setInterval(() => {
|
||||
this.time.innerText = new Date().toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}, 60000);
|
||||
this.updateBatteryLevel(batteryLevel);
|
||||
this.updateGeneralSettings(initSettings);
|
||||
}
|
||||
const { div, p, img, button } = van.tags;
|
||||
|
||||
refresh = () => {
|
||||
this.chapterHeight = this.chapter.scrollHeight + this.paddingTop;
|
||||
};
|
||||
post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
||||
updateReaderSettings = settings => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-theme',
|
||||
settings.theme,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-padding',
|
||||
settings.padding + '%',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textSize',
|
||||
settings.textSize + 'px',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textColor',
|
||||
settings.textColor,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textAlign',
|
||||
settings.textAlign,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-lineHeight',
|
||||
settings.lineHeight,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-fontFamily',
|
||||
settings.fontFamily,
|
||||
);
|
||||
if (settings.fontFamily) {
|
||||
new FontFace(
|
||||
settings.fontFamily,
|
||||
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
||||
)
|
||||
.load()
|
||||
.then(function (loadedFont) {
|
||||
document.fonts.add(loadedFont);
|
||||
});
|
||||
} else {
|
||||
// have no affect with a font declared in head
|
||||
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
|
||||
}
|
||||
};
|
||||
updateGeneralSettings = settings => {
|
||||
this.showScrollPercentage = settings.showScrollPercentage;
|
||||
this.showBatteryAndTime = settings.showBatteryAndTime;
|
||||
this.verticalSeekbar = settings.verticalSeekbar;
|
||||
this.bionicReading = settings.bionicReading;
|
||||
if (this.bionicReading) {
|
||||
this.chapter.innerHTML = textVide.textVide(this.rawHTML);
|
||||
} else {
|
||||
this.chapter.innerHTML = this.rawHTML;
|
||||
}
|
||||
if (settings.swipeGestures) {
|
||||
swipeHandler.enable();
|
||||
} else {
|
||||
swipeHandler.disable();
|
||||
}
|
||||
if (!this.showScrollPercentage && !this.showBatteryAndTime) {
|
||||
this.footerWrapper.classList.add('d-none');
|
||||
} else {
|
||||
this.footerWrapper.classList.remove('d-none');
|
||||
if (this.showScrollPercentage) {
|
||||
this.percentage.classList.remove('hidden');
|
||||
} else {
|
||||
this.percentage.classList.add('hidden');
|
||||
}
|
||||
if (this.showBatteryAndTime) {
|
||||
this.battery.classList.remove('hidden');
|
||||
this.time.classList.remove('hidden');
|
||||
} else {
|
||||
this.battery.classList.add('hidden');
|
||||
this.time.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (!this.verticalSeekbar) {
|
||||
toolWrapper.$.classList.add('horizontal');
|
||||
} else {
|
||||
toolWrapper.$.classList.remove('horizontal');
|
||||
}
|
||||
if (scrollHandler) {
|
||||
scrollHandler.refresh();
|
||||
scrollHandler.update();
|
||||
}
|
||||
};
|
||||
updateBatteryLevel = level => {
|
||||
this.battery.innerText = Math.ceil(level * 100) + '%';
|
||||
};
|
||||
}
|
||||
const ChapterEnding = () => {
|
||||
return () =>
|
||||
reader.generalSettings.val.pageReader
|
||||
? div()
|
||||
: div(div({ class: 'info-text' }, reader.strings.finished), () =>
|
||||
reader.nextChapter
|
||||
? button(
|
||||
{
|
||||
class: 'next-button',
|
||||
onclick: () => reader.post({ type: 'next' }),
|
||||
},
|
||||
reader.strings.nextChapter,
|
||||
)
|
||||
: div({ class: 'info-text' }, reader.strings.noNextChapter),
|
||||
);
|
||||
};
|
||||
|
||||
class ToolWrapper {
|
||||
constructor() {
|
||||
this.$ = document.getElementById('ToolWrapper');
|
||||
this.$.onclick = e => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
this.tools = [];
|
||||
}
|
||||
hide = () => {
|
||||
this.$.classList.add('hidden');
|
||||
this.visible = false;
|
||||
};
|
||||
show = () => {
|
||||
this.$.classList.remove('hidden');
|
||||
this.visible = true;
|
||||
for (const tool of this.tools) {
|
||||
tool.onShow?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class ScrollHandler {
|
||||
constructor(reader, toolWrapper) {
|
||||
this.reader = reader;
|
||||
this.toolWrapper = toolWrapper;
|
||||
this.$ = document.getElementById('ScrollBar');
|
||||
this.$.innerHTML = `
|
||||
<div class="scrollbar-item scrollbar-text" id="scrollbar-percentage">
|
||||
0
|
||||
</div>
|
||||
<div class="scrollbar-item" id="scrollbar-slider">
|
||||
<div id="scrollbar-track">
|
||||
<div id="scrollbar-progress">
|
||||
<div id="scrollbar-thumb-wrapper">
|
||||
<div id="scrollbar-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="scrollbar-item scrollbar-text">
|
||||
100
|
||||
</div>
|
||||
`;
|
||||
this.percentage = this.$.querySelector('#scrollbar-percentage');
|
||||
this.progress = this.$.querySelector('#scrollbar-progress');
|
||||
this.thumb = this.$.querySelector('#scrollbar-thumb-wrapper');
|
||||
this.slider = this.$.querySelector('#scrollbar-slider');
|
||||
this.sliderHeight = this.slider.clientHeight;
|
||||
this.sliderOffsetY = this.slider.getBoundingClientRect().top;
|
||||
this.lock = false;
|
||||
window.onscroll = () => !this.lock && this.update();
|
||||
this.thumb.ontouchstart = () => (this.lock = true);
|
||||
this.thumb.ontouchend = () => (this.lock = false);
|
||||
this.thumb.ontouchmove = e => {
|
||||
const ratio =
|
||||
((this.reader.verticalSeekbar
|
||||
? e.changedTouches[0].clientY
|
||||
: e.changedTouches[0].clientX) -
|
||||
this.sliderOffsetY) /
|
||||
this.sliderHeight;
|
||||
this.update(ratio < 0 ? 0 : ratio);
|
||||
};
|
||||
}
|
||||
update = ratio => {
|
||||
const Scrollbar = () => {
|
||||
const horizontal = van.derive(
|
||||
() => !reader.generalSettings.val.verticalSeekbar,
|
||||
);
|
||||
let lock = false;
|
||||
const percentage = van.state(0);
|
||||
const update = ratio => {
|
||||
if (ratio === undefined) {
|
||||
ratio =
|
||||
(window.scrollY + this.reader.layoutHeight) / this.reader.chapterHeight;
|
||||
ratio = (window.scrollY + reader.layoutHeight) / reader.chapterHeight;
|
||||
}
|
||||
if (ratio > 1) {
|
||||
ratio = 1;
|
||||
}
|
||||
const percentage = parseInt(ratio * 100);
|
||||
if (this.toolWrapper.visible) {
|
||||
if (this.reader.verticalSeekbar) {
|
||||
this.progress.style.height = percentage + '%';
|
||||
this.progress.style.width = '100%';
|
||||
} else {
|
||||
this.progress.style.height = '100%';
|
||||
this.progress.style.width = percentage + '%';
|
||||
}
|
||||
this.percentage.innerText = percentage;
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
pageReader.movePage(
|
||||
parseInt(pageReader.totalPages.val * Math.min(0.99, ratio)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.lock) {
|
||||
percentage.val = parseInt(ratio * 100);
|
||||
if (lock) {
|
||||
window.scrollTo({
|
||||
top: this.reader.chapterHeight * ratio - this.reader.layoutHeight,
|
||||
top: reader.chapterHeight * ratio - reader.layoutHeight,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
if (this.reader.percentage) {
|
||||
this.reader.percentage.innerText = percentage + '%';
|
||||
}
|
||||
};
|
||||
refresh = () => {
|
||||
if (this.reader.verticalSeekbar) {
|
||||
this.sliderHeight = this.slider.clientHeight;
|
||||
this.sliderOffsetY = this.slider.getBoundingClientRect().top;
|
||||
} else {
|
||||
this.sliderHeight = this.slider.clientWidth;
|
||||
this.sliderOffsetY = this.slider.getBoundingClientRect().left;
|
||||
}
|
||||
};
|
||||
onShow = () => {
|
||||
this.reader.refresh();
|
||||
this.refresh();
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
() => !lock && !reader.generalSettings.val.pageReader && update(),
|
||||
);
|
||||
return div(
|
||||
{ id: 'ScrollBar' },
|
||||
div(
|
||||
{ class: 'scrollbar-item scrollbar-text', id: 'scrollbar-percentage' },
|
||||
() =>
|
||||
reader.generalSettings.val.pageReader
|
||||
? pageReader.page.val + 1
|
||||
: percentage.val,
|
||||
),
|
||||
div(
|
||||
{ class: 'scrollbar-item', id: 'scrollbar-slider' },
|
||||
div(
|
||||
{ id: 'scrollbar-track' },
|
||||
div(
|
||||
{
|
||||
id: 'scrollbar-progress',
|
||||
style: () => {
|
||||
const percentageValue = reader.generalSettings.val.pageReader
|
||||
? ((pageReader.page.val + 1) / pageReader.totalPages.val) * 100
|
||||
: percentage.val;
|
||||
return horizontal.val
|
||||
? `width: ${percentageValue}%; height: 100%;`
|
||||
: `height: ${percentageValue}%; width: 100%;`;
|
||||
},
|
||||
},
|
||||
div(
|
||||
{
|
||||
id: 'scrollbar-thumb-wrapper',
|
||||
ontouchstart: () => {
|
||||
lock = true;
|
||||
},
|
||||
ontouchend: () => {
|
||||
lock = false;
|
||||
},
|
||||
ontouchmove: function (e) {
|
||||
const slider = this.parentElement.parentElement.parentElement;
|
||||
const sliderHeight = horizontal.val
|
||||
? slider.clientWidth
|
||||
: slider.clientHeight;
|
||||
const sliderOffsetY = horizontal.val
|
||||
? slider.getBoundingClientRect().left
|
||||
: slider.getBoundingClientRect().top;
|
||||
const ratio =
|
||||
((horizontal.val
|
||||
? e.changedTouches[0].clientX
|
||||
: e.changedTouches[0].clientY) -
|
||||
sliderOffsetY) /
|
||||
sliderHeight;
|
||||
update(ratio < 0 ? 0 : ratio);
|
||||
},
|
||||
},
|
||||
div({ id: 'scrollbar-thumb' }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
div(
|
||||
{
|
||||
class: 'scrollbar-item scrollbar-text',
|
||||
id: 'scrollbar-percentage-max',
|
||||
},
|
||||
() =>
|
||||
reader.generalSettings.val.pageReader ? pageReader.totalPages.val : 100,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
class SwipeHandler {
|
||||
constructor() {
|
||||
this.initialX = null;
|
||||
this.initialY = null;
|
||||
}
|
||||
const ToolWrapper = () => {
|
||||
const horizontal = van.derive(
|
||||
() => !reader.generalSettings.val.verticalSeekbar,
|
||||
);
|
||||
return div(
|
||||
{
|
||||
id: 'ToolWrapper',
|
||||
class: () =>
|
||||
`${reader.hidden.val ? 'hidden' : ''} ${
|
||||
horizontal.val ? 'horizontal' : ''
|
||||
}`,
|
||||
},
|
||||
Scrollbar(),
|
||||
);
|
||||
};
|
||||
|
||||
touchStartHandler = e => {
|
||||
this.initialX = e.changedTouches[0].screenX;
|
||||
this.initialY = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
touchEndHandler = e => {
|
||||
let diffX = e.changedTouches[0].screenX - this.initialX;
|
||||
let diffY = e.changedTouches[0].screenY - this.initialY;
|
||||
if (
|
||||
e.target.id?.startsWith('scrollbar') ||
|
||||
e.target.id === 'Image-Modal-img'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(diffX) > Math.abs(diffY) * 2 && Math.abs(diffX) > 180) {
|
||||
if (diffX < 0 && this.initialX >= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'next' });
|
||||
} else if (diffX > 0 && this.initialX <= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'prev' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
enable = () => {
|
||||
document.addEventListener('touchstart', this.touchStartHandler);
|
||||
document.addEventListener('touchend', this.touchEndHandler);
|
||||
};
|
||||
|
||||
disable = () => {
|
||||
document.removeEventListener('touchstart', this.touchStartHandler);
|
||||
document.removeEventListener('touchend', this.touchEndHandler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('./type').TextToSpeech}
|
||||
*/
|
||||
|
||||
// Walk through all leave -> convert each leaf to list of tts elements -> read through all tts elements
|
||||
class TextToSpeech {
|
||||
constructor(reader) {
|
||||
this.reader = reader;
|
||||
this.readableNodeNames = ['#text', 'B', 'I', 'SPAN', 'EM', 'BR', 'STRONG'];
|
||||
this.leaf = this.reader.chapter;
|
||||
this.reading = false;
|
||||
this.started = false;
|
||||
this.$ = document.getElementById('TTS-Controller');
|
||||
this.$.classList.add('d-none');
|
||||
this.$.onclick = () => {
|
||||
if (this.reading) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.resume();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
start = () => {
|
||||
this.started = false;
|
||||
this.$.innerHTML = `<button>${pauseIcon}</button>`;
|
||||
this.$.classList.remove('d-none');
|
||||
this.stop();
|
||||
this.leaf = this.reader.chapter;
|
||||
this.next();
|
||||
};
|
||||
|
||||
startHere = () => {
|
||||
this.$.innerHTML = `<button>${pauseIcon}</button>`;
|
||||
this.$.classList.remove('d-none');
|
||||
this.stop();
|
||||
if (this.reader.selection.type === 'Range') {
|
||||
this.leaf = this.reader.selection.anchorNode;
|
||||
while (this.readableNodeNames.includes(this.leaf.nodeName)) {
|
||||
this.leaf = this.leaf.parentNode;
|
||||
}
|
||||
this.reader.selection.removeAllRanges();
|
||||
this.speak();
|
||||
} else {
|
||||
this.next();
|
||||
}
|
||||
};
|
||||
|
||||
resume = () => {
|
||||
this.$.innerHTML = `<button>${pauseIcon}</button>`;
|
||||
this.next();
|
||||
};
|
||||
|
||||
readable() {
|
||||
if (
|
||||
this.leaf.nodeName !== 'SPAN' &&
|
||||
this.readableNodeNames.includes(this.leaf.nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!this.leaf.hasChildNodes()) {
|
||||
return false;
|
||||
}
|
||||
const a = [];
|
||||
for (let i = 0; i < this.leaf.childNodes.length; i++) {
|
||||
if (
|
||||
!this.readableNodeNames.includes(this.leaf.childNodes.item(i).nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
findNextTextNode() {
|
||||
do {
|
||||
if (this.reader.chapter.isSameNode(this.leaf)) {
|
||||
this.leaf = this.leaf.firstElementChild;
|
||||
} else {
|
||||
if (this.leaf.firstElementChild) {
|
||||
this.leaf = this.leaf.firstElementChild;
|
||||
} else if (this.leaf.nextElementSibling) {
|
||||
this.leaf = this.leaf.nextElementSibling;
|
||||
} else {
|
||||
this.leaf = this.leaf.parentElement;
|
||||
const ImageModal = ({ src }) => {
|
||||
return div(
|
||||
{
|
||||
id: 'Image-Modal',
|
||||
class: () => (src.val ? 'show' : ''),
|
||||
onclick: e => {
|
||||
if (e.target.id !== 'Image-Modal-img') {
|
||||
e.stopPropagation();
|
||||
src.val = '';
|
||||
}
|
||||
}
|
||||
} while (!this.readable());
|
||||
if (this.reader.chapter.isSameNode(this.leaf)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
img({
|
||||
id: 'Image-Modal-img',
|
||||
src: src,
|
||||
alt: () => (src.val ? `Cant not render image from ${src.val}` : ''),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
next = () => {
|
||||
try {
|
||||
this.leaf?.classList?.remove('highlight');
|
||||
if (this.reading) {
|
||||
if (this.leaf.nextElementSibling) {
|
||||
this.leaf = this.leaf.nextElementSibling;
|
||||
} else {
|
||||
this.findNextTextNode();
|
||||
}
|
||||
} else if (!this.started) {
|
||||
this.findNextTextNode();
|
||||
this.started = true;
|
||||
}
|
||||
this.speak();
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
|
||||
stop = () => {
|
||||
this.reader.post({ type: 'stop-speak' });
|
||||
this.$.classList.add('d-none');
|
||||
this.leaf?.classList?.remove('highlight');
|
||||
this.reading = false;
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
this.$.innerHTML = `<button>${resumeIcon}</button>`;
|
||||
this.reading = false;
|
||||
this.reader.post({ type: 'stop-speak' });
|
||||
};
|
||||
|
||||
speak = () => {
|
||||
this.reading = true;
|
||||
if (this.reader.chapter.isSameNode(this.leaf)) {
|
||||
return;
|
||||
}
|
||||
this.leaf.classList.add('highlight');
|
||||
this.reader.post({ type: 'speak', data: this.leaf?.innerText });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("./type").ContextMenu}
|
||||
*/
|
||||
class ContextMenu {
|
||||
/**
|
||||
* @param {import("./type").Reader} reader
|
||||
*/
|
||||
constructor(reader) {
|
||||
this.reader = reader;
|
||||
this.items = {
|
||||
START_READING: this.renderItem({
|
||||
name: 'Start Reading',
|
||||
icon: volumeIcon,
|
||||
action: () => {
|
||||
this.closeMenu();
|
||||
tts.start();
|
||||
},
|
||||
}),
|
||||
START_HERE: this.renderItem({
|
||||
name: 'Start Here',
|
||||
icon: selectVolumeIcon,
|
||||
action: () => {
|
||||
this.closeMenu();
|
||||
tts.startHere();
|
||||
},
|
||||
}),
|
||||
COPY: this.renderItem({
|
||||
name: 'Copy',
|
||||
icon: copyIcon,
|
||||
action: () => {
|
||||
this.closeMenu();
|
||||
this.reader.post({
|
||||
type: 'copy',
|
||||
data: this.reader.selection.toString(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
SELECT_ALL: this.renderItem({
|
||||
name: 'Select All',
|
||||
icon: selectAllIcon,
|
||||
action: () => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this.reader.chapter);
|
||||
this.reader.selection.removeAllRanges();
|
||||
this.reader.selection.addRange(range);
|
||||
},
|
||||
}),
|
||||
};
|
||||
this.commonItems = [this.items.COPY, this.items.SELECT_ALL];
|
||||
this.contextMenu = document.createElement('ul');
|
||||
this.contextMenu.classList.add('contextMenu');
|
||||
this.isOpened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{name: string, icon: string, action: () => void}} data
|
||||
* @returns
|
||||
*/
|
||||
renderItem(data) {
|
||||
const item = document.createElement('li');
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = `${data.icon} <span>${data.name}</span>`;
|
||||
button.classList.add('contextMenu-button');
|
||||
item.classList.add('contextMenu-item');
|
||||
item.appendChild(button);
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
data.action();
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
renderMenu(items) {
|
||||
this.contextMenu.innerHTML = '';
|
||||
items.concat(this.commonItems).forEach((item, index) => {
|
||||
item.firstChild.setAttribute('style', `animation-delay: ${index * 0.1}s`);
|
||||
this.contextMenu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
if (this.isOpened) {
|
||||
this.isOpened = false;
|
||||
this.contextMenu.remove();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.reader.chapter.addEventListener('click', () => {
|
||||
this.closeMenu(this.contextMenu);
|
||||
});
|
||||
document.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (e.target instanceof HTMLImageElement) {
|
||||
if (imageModal.showing) {
|
||||
imageModal.hide();
|
||||
} else {
|
||||
imageModal.show(e.target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.isOpened = true;
|
||||
if (this.reader.selection.type === 'Range') {
|
||||
this.renderMenu([this.items.START_HERE, this.items.START_READING]);
|
||||
} else {
|
||||
this.renderMenu([this.items.START_READING]);
|
||||
}
|
||||
const { clientX, clientY } = e;
|
||||
e.target.appendChild(this.contextMenu);
|
||||
const positionY =
|
||||
clientY + this.contextMenu.scrollHeight >= window.innerHeight
|
||||
? window.innerHeight - this.contextMenu.scrollHeight - 20
|
||||
: clientY;
|
||||
const positionX =
|
||||
clientX + this.contextMenu.scrollWidth >= window.innerWidth
|
||||
? window.innerWidth - this.contextMenu.scrollWidth - 20
|
||||
: clientX;
|
||||
this.contextMenu.setAttribute(
|
||||
'style',
|
||||
`--width: ${this.contextMenu.scrollWidth}px;
|
||||
--height: ${this.contextMenu.scrollHeight}px;
|
||||
--top: ${positionY}px;
|
||||
--left: ${positionX}px;`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("./type").ImageModal}
|
||||
*/
|
||||
class ImageModal {
|
||||
/**
|
||||
* @param {import('./type').Reader} reader
|
||||
*/
|
||||
constructor(reader) {
|
||||
this.reader = reader;
|
||||
this.$ = document.getElementById('Image-Modal');
|
||||
this.img = this.$.querySelector('img');
|
||||
this.showing = false;
|
||||
this.$.onclick = e => {
|
||||
if (e.target.id !== 'Image-Modal-img') {
|
||||
e.stopPropagation();
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
this.$.ontouchmove = e => {
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLImageElement} image
|
||||
*/
|
||||
show(image) {
|
||||
this.img.src = image.src;
|
||||
this.img.alt = `Can not render image from ${image.src}`;
|
||||
this.reader.viewport.setAttribute(
|
||||
const ModalWrapper = () => {
|
||||
const imgSrc = van.state('');
|
||||
const showImage = src => {
|
||||
imgSrc.val = src;
|
||||
reader.viewport.setAttribute(
|
||||
'content',
|
||||
'width=device-width, initial-scale=1.0, maximum-scale=10',
|
||||
);
|
||||
this.$.classList.add('show');
|
||||
this.showing = true;
|
||||
}
|
||||
hide() {
|
||||
this.$.classList.remove('show');
|
||||
this.showing = false;
|
||||
this.img.src = '';
|
||||
this.img.alt = '';
|
||||
this.reader.viewport.setAttribute(
|
||||
};
|
||||
const hideImage = () => {
|
||||
imgSrc.val = '';
|
||||
reader.viewport.setAttribute(
|
||||
'content',
|
||||
'width=device-width, initial-scale=1.0, maximum-scale=1.0',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
var swipeHandler = new SwipeHandler();
|
||||
var toolWrapper = new ToolWrapper();
|
||||
var reader = new Reader();
|
||||
var scrollHandler = new ScrollHandler(reader, toolWrapper);
|
||||
var tts = new TextToSpeech(reader);
|
||||
toolWrapper.tools = [scrollHandler, tts];
|
||||
imageModal = new ImageModal(reader);
|
||||
const contextMenu = new ContextMenu(reader);
|
||||
contextMenu.init();
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
document.addEventListener('contextmenu', e => {
|
||||
if (e.target instanceof HTMLImageElement) {
|
||||
if (!imgSrc.val) {
|
||||
showImage(e.target.src);
|
||||
} else {
|
||||
hideImage();
|
||||
}
|
||||
}
|
||||
});
|
||||
return div(ImageModal({ src: imgSrc }));
|
||||
};
|
||||
|
||||
const Footer = () => {
|
||||
const percentage = van.state(0);
|
||||
const time = van.state(
|
||||
new Date().toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
);
|
||||
window.addEventListener('scroll', () => {
|
||||
let ratio = (window.scrollY + reader.layoutHeight) / reader.chapterHeight;
|
||||
if (ratio > 1) {
|
||||
ratio = 1;
|
||||
}
|
||||
percentage.val = parseInt(ratio * 100);
|
||||
});
|
||||
setInterval(() => {
|
||||
time.val = new Date().toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}, 10000);
|
||||
return div(
|
||||
{
|
||||
id: 'reader-footer-wrapper',
|
||||
class: () =>
|
||||
reader.generalSettings.val.showBatteryAndTime ||
|
||||
reader.generalSettings.val.showScrollPercentage
|
||||
? ''
|
||||
: 'd-none',
|
||||
},
|
||||
div(
|
||||
{ id: 'reader-footer' },
|
||||
|
||||
div(
|
||||
{
|
||||
id: 'reader-battery',
|
||||
class: () =>
|
||||
`reader-footer-item ${
|
||||
reader.generalSettings.val.showBatteryAndTime ? '' : 'hidden'
|
||||
}`,
|
||||
},
|
||||
() => Math.ceil(reader.batteryLevel.val * 100) + '%',
|
||||
),
|
||||
div(
|
||||
{
|
||||
id: 'reader-percentage',
|
||||
class: () =>
|
||||
`reader-footer-item ${
|
||||
reader.generalSettings.val.showScrollPercentage ? '' : 'hidden'
|
||||
}`,
|
||||
},
|
||||
() =>
|
||||
reader.generalSettings.val.pageReader
|
||||
? `${pageReader.page.val + 1}/${pageReader.totalPages.val}`
|
||||
: percentage.val + '%',
|
||||
),
|
||||
div(
|
||||
{
|
||||
id: 'reader-time',
|
||||
class: () =>
|
||||
`reader-footer-item ${
|
||||
reader.generalSettings.val.showBatteryAndTime ? '' : 'hidden'
|
||||
}`,
|
||||
},
|
||||
time,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const TTSController = () => {
|
||||
let controllerElement = null;
|
||||
let hoverElement = null;
|
||||
let clientX = null;
|
||||
let clientY = null;
|
||||
return div(
|
||||
{
|
||||
id: 'TTS-Controller',
|
||||
class: () => `${reader.generalSettings.val.TTSEnable ? '' : 'hidden'}`,
|
||||
ontouchstart: () => {
|
||||
if (!controllerElement) {
|
||||
controllerElement = document.getElementById('TTS-Controller');
|
||||
}
|
||||
controllerElement.classList.add('active');
|
||||
controllerElement.style.transition = '';
|
||||
},
|
||||
ontouchmove: e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clientX = e.changedTouches[0].clientX;
|
||||
clientY = e.changedTouches[0].clientY;
|
||||
controllerElement.style.left = `${clientX}px`;
|
||||
controllerElement.style.top = `${clientY}px`;
|
||||
const hoverElements = document.elementsFromPoint(clientX, clientY);
|
||||
const newHoverElement = hoverElements.reverse().find(e => {
|
||||
if (e.id.includes('scrollbar')) {
|
||||
return false;
|
||||
}
|
||||
return tts.readable(e);
|
||||
});
|
||||
hoverElement?.classList.remove('highlight');
|
||||
if (newHoverElement) {
|
||||
newHoverElement.classList.add('highlight');
|
||||
hoverElement = newHoverElement;
|
||||
} else {
|
||||
hoverElement = null;
|
||||
}
|
||||
},
|
||||
ontouchend: () => {
|
||||
controllerElement.style.transition = '1s';
|
||||
controllerElement.classList.remove('active');
|
||||
controllerElement.style.left = '20px';
|
||||
if (clientX && clientY) {
|
||||
let top = clientY < 120 ? 120 : clientY;
|
||||
if (top + 120 > reader.layoutHeight) {
|
||||
top = reader.layoutHeight - 120;
|
||||
}
|
||||
controllerElement.style.top = `${top}px`;
|
||||
if (hoverElement) {
|
||||
tts.start(hoverElement);
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
}
|
||||
clientX = null;
|
||||
clientY = null;
|
||||
},
|
||||
onclick: e => {
|
||||
e.stopPropagation();
|
||||
if (tts.reading) {
|
||||
tts.pause();
|
||||
controllerElement.firstElementChild.innerHTML = resumeIcon;
|
||||
} else {
|
||||
tts.resume();
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
},
|
||||
},
|
||||
button({ innerHTML: volumnIcon }),
|
||||
);
|
||||
};
|
||||
|
||||
const ReaderUI = () => {
|
||||
return div(
|
||||
ToolWrapper(),
|
||||
TTSController(),
|
||||
ModalWrapper(),
|
||||
Footer(),
|
||||
ChapterEnding(),
|
||||
);
|
||||
};
|
||||
|
||||
van.add(document.getElementById('reader-ui'), ReaderUI());
|
||||
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
ChapterGeneralSettings,
|
||||
ChapterReaderSettings,
|
||||
} from '@hooks/persisted/useSettings';
|
||||
import 'typescript';
|
||||
import 'typescript/lib/lib.dom';
|
||||
|
||||
interface Reader {
|
||||
selection: Selection;
|
||||
viewport: HTMLMetaElement;
|
||||
footerWrapper: HTMLDivElement;
|
||||
percentage: HTMLDivElement;
|
||||
battery: HTMLDivElement;
|
||||
time: HTMLDivElement;
|
||||
paddingTop: number;
|
||||
chapter: HTMLElement;
|
||||
chapterHeight: number;
|
||||
layoutHeight: number;
|
||||
pluginId: string;
|
||||
novelId: string;
|
||||
chapterId: string;
|
||||
saveProgressInterval: NodeJS.Timeout;
|
||||
timeInterval: NodeJS.Timeout;
|
||||
refresh: () => void;
|
||||
post: (message: { type: string; data?: any }) => void;
|
||||
updateReaderSettings: (settings: ChapterReaderSettings) => void;
|
||||
updateGeneralSettings: (settings: ChapterGeneralSettings) => void;
|
||||
updateBatteryLevel: (level: number) => void;
|
||||
}
|
||||
|
||||
interface TextToSpeech {
|
||||
reader: Reader;
|
||||
leaf: HTMLUnknownElement;
|
||||
reading: boolean;
|
||||
readableTags: string[];
|
||||
start: () => void;
|
||||
startHere: () => void;
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
started: () => boolean;
|
||||
}
|
||||
|
||||
export enum ContextMenuItem {
|
||||
START_READING = 'START_READING',
|
||||
START_HERE = 'START_HERE',
|
||||
COPY = 'COPY',
|
||||
SELECT_ALL = 'SELECT_ALL',
|
||||
}
|
||||
interface ContextMenu {
|
||||
reader: Reader;
|
||||
contextMenu: HTMLUListElement;
|
||||
items: Record<ContextMenuItem, HTMLLIElement>;
|
||||
renderItem: (data: {
|
||||
name: string;
|
||||
icon: string;
|
||||
action: () => void;
|
||||
}) => HTMLLIElement;
|
||||
renderMenu: (items: HTMLLIElement[]) => void;
|
||||
closeMenu: (menu: HTMLUListElement) => void;
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
interface ImageModal {
|
||||
reader: Reader;
|
||||
showing: boolean;
|
||||
show: (image: HTMLImageElement) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import 'typescript';
|
||||
import 'typescript/lib/lib.dom';
|
||||
export interface State<T> {
|
||||
val: T;
|
||||
readonly oldVal: T;
|
||||
readonly rawVal: T;
|
||||
}
|
||||
|
||||
// Defining readonly view of State<T> for covariance.
|
||||
// Basically we want StateView<string> to implement StateView<string | number>
|
||||
export type StateView<T> = Readonly<State<T>>;
|
||||
|
||||
export type Val<T> = State<T> | T;
|
||||
|
||||
export type Primitive = string | number | boolean | bigint;
|
||||
|
||||
export type PropValue = Primitive | ((e: any) => void) | null;
|
||||
|
||||
export type PropValueOrDerived =
|
||||
| PropValue
|
||||
| StateView<PropValue>
|
||||
| (() => PropValue);
|
||||
|
||||
export type Props = Record<string, PropValueOrDerived> & {
|
||||
class?: PropValueOrDerived;
|
||||
};
|
||||
|
||||
export type PropsWithKnownKeys<ElementType> = Partial<{
|
||||
[K in keyof ElementType]: PropValueOrDerived;
|
||||
}>;
|
||||
|
||||
export type ValidChildDomValue = Primitive | Node | null | undefined;
|
||||
|
||||
export type BindingFunc =
|
||||
| ((dom?: Node) => ValidChildDomValue)
|
||||
| ((dom?: Element) => Element);
|
||||
|
||||
export type ChildDom =
|
||||
| ValidChildDomValue
|
||||
| StateView<Primitive | null | undefined>
|
||||
| BindingFunc
|
||||
| readonly ChildDom[];
|
||||
|
||||
export type TagFunc<Result> = (
|
||||
first?: (Props & PropsWithKnownKeys<Result>) | ChildDom,
|
||||
...rest: readonly ChildDom[]
|
||||
) => Result;
|
||||
|
||||
type Tags = Readonly<Record<string, TagFunc<Element>>> & {
|
||||
[K in keyof HTMLElementTagNameMap]: TagFunc<HTMLElementTagNameMap[K]>;
|
||||
};
|
||||
|
||||
declare function state<T>(): State<T>;
|
||||
declare function state<T>(initVal: T): State<T>;
|
||||
|
||||
export interface Van {
|
||||
readonly state: typeof state;
|
||||
readonly derive: <T>(f: () => T) => State<T>;
|
||||
readonly add: (dom: Element, ...children: readonly ChildDom[]) => Element;
|
||||
readonly tags: Tags &
|
||||
((namespaceURI: string) => Readonly<Record<string, TagFunc<Element>>>);
|
||||
readonly hydrate: <T extends Node>(
|
||||
dom: T,
|
||||
f: (dom: T) => T | null | undefined,
|
||||
) => T;
|
||||
}
|
||||
|
||||
declare global {
|
||||
const van: Van;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{let e,t,r,o,n,l,s,f,i,h,w,a,d,u,_,c,S,g,y,b,m,v,j,x,O; s = Object.getPrototypeOf,i = {},h = s(f = {isConnected:1}),w = s(s),a = (e,t,r,o)=>(e ?? (setTimeout(r,o),new Set())).add(t),d = (e,t,o)=>{let n = r; r = t; try {return e(o);} catch (e){return console.error(e),o;} finally {r = n;}},u = e=>e.filter(e=>e.t?.isConnected),_ = e=>n = a(n,e,()=>{for (let e of n){e.o = u(e.o),e.l = u(e.l);}n = l;},1e3),c = {get val(){return r?.i?.add(this),this.rawVal;},get oldVal(){return r?.i?.add(this),this.h;},set val(o){r?.u?.add(this),o !== this.rawVal && (this.rawVal = o,this.o.length + this.l.length ? (t?.add(this),e = a(e,this,x)) : this.h = o);}},S = e=>({__proto__:c,rawVal:e,h:e,o:[],l:[]}),g = (e,t)=>{let r = {i:new Set(),u:new Set()},n = {f:e},l = o; o = []; let s = d(e,r,t); s = (s ?? document).nodeType ? s : new Text(s); for (let e of r.i){r.u.has(e) || (_(e),e.o.push(n));} for (let e of o){e.t = s;} return o = l,n.t = s;},y = (e,t = S(),r)=>{let n = {i:new Set(),u:new Set()},l = {f:e,s:t}; l.t = r ?? o?.push(l) ?? f,t.val = d(e,n,t.rawVal); for (let e of n.i){n.u.has(e) || (_(e),e.l.push(l));} return t;},b = (e,...t)=>{for (let r of t.flat(1 / 0)){let t = s(r ?? 0),o = t === c ? g(()=>r.val) : t === w ? g(r) : r; o != l && e.append(o);} return e;},m = (e,t,...r)=>{let [o,...n] = s(r[0] ?? 0) === h ? r : [{},...r],f = e ? document.createElementNS(e,t) : document.createElement(t); for (let [e,r] of Object.entries(o)){let o = t=>t ? Object.getOwnPropertyDescriptor(t,e) ?? o(s(t)) : l,n = t + ',' + e,h = i[n] ?? (i[n] = o(s(f))?.set ?? 0),a = e.startsWith('on') ? (t,r)=>{let o = e.slice(2); f.removeEventListener(o,r),f.addEventListener(o,t);} : h ? h.bind(f) : f.setAttribute.bind(f,e),d = s(r ?? 0); e.startsWith('on') || d === w && (r = y(r),d = c),d === c ? g(()=>(a(r.val,r.h),f)) : a(r);} return b(f,n);},v = e=>({get:(t,r)=>m.bind(l,e,r)}),j = (e,t)=>t ? t !== e && e.replaceWith(t) : e.remove(),x = ()=>{let r = 0,o = [...e].filter(e=>e.rawVal !== e.h); do {t = new Set(); for (let e of new Set(o.flatMap(e=>e.l = u(e.l)))){y(e.f,e.s,e.t),e.t = l;}} while (++r < 100 && (o = [...t]).length); let n = [...e].filter(e=>e.rawVal !== e.h); e = l; for (let e of new Set(n.flatMap(e=>e.o = u(e.o)))){j(e.t,g(e.f,e.t)),e.t = l;} for (let e of n){e.h = e.rawVal;}},O = {tags:new Proxy(e=>new Proxy(m,v(e)),v()),hydrate:(e,t)=>j(e,g(t,e)),add:b,state:S,derive:y},window.van = O;}
|
||||
@@ -124,12 +124,12 @@ class EpubUtil(context: ReactApplicationContext) : ReactContextBaseJavaModule(co
|
||||
if (tocFile.exists()) {
|
||||
val tocParser = initParse(tocFile)
|
||||
var label = ""
|
||||
while(tocParser.next() != XmlPullParser.END_DOCUMENT){
|
||||
if(tocParser.name == "navMap") break;
|
||||
while (tocParser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (tocParser.name == "navMap") break
|
||||
}
|
||||
while (tocParser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
val tag = tocParser.name
|
||||
if(tag == "navMap") break;
|
||||
if (tag == "navMap") break
|
||||
if (tag != null) {
|
||||
if (tag == "text") {
|
||||
label = readText(tocParser)
|
||||
@@ -143,7 +143,7 @@ class EpubUtil(context: ReactApplicationContext) : ReactContextBaseJavaModule(co
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw Error("Table of content doesn't exist!")
|
||||
throw Exception("Table of content (toc.ncx) does not exist!")
|
||||
}
|
||||
var cover: String? = null
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
pull_request_title: 'chore: Update translations'
|
||||
pull_request_title: 'chore: Update Translations'
|
||||
commit_message: '[ci skip]'
|
||||
files:
|
||||
- source: /strings/languages/en/strings.json
|
||||
|
||||
Generated
+4187
-5359
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -8,13 +8,14 @@
|
||||
"prestart": "node scripts/setEnvFile.cjs Debug",
|
||||
"start": "react-native start",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"prettier": "prettier --write \"./src/**/*.{js,jsx,ts,tsx}\"",
|
||||
"prettier": "prettier --write \"./src/**/*.{js,jsx,ts,tsx}\" ./scripts",
|
||||
"buildRelease": "node scripts/setEnvFile.cjs Release && cd android && ./gradlew clean && ./gradlew assembleRelease",
|
||||
"open": "open ./android/app/build/outputs/apk/release/",
|
||||
"strings": "node scripts/stringTypes.cjs",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cd-z/react-native-epub-creator": "^2.0.4",
|
||||
"@gorhom/bottom-sheet": "^4.4.5",
|
||||
"@react-native-community/slider": "^4.4.2",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
@@ -43,7 +44,6 @@
|
||||
"i18n-js": "^3.8.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"protobufjs": "^7.2.6",
|
||||
"qs": "^6.12.0",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.72.10",
|
||||
"react-native-background-actions": "^3.0.1",
|
||||
@@ -56,12 +56,15 @@
|
||||
"react-native-pager-view": "6.2.0",
|
||||
"react-native-paper": "^5.12.0",
|
||||
"react-native-reanimated": "^3.3.0",
|
||||
"react-native-saf-x": "^2.2.3",
|
||||
"react-native-safe-area-context": "4.6.3",
|
||||
"react-native-screens": "^3.22.0",
|
||||
"react-native-shimmer-placeholder": "^2.0.9",
|
||||
"react-native-tab-view": "^3.5.2",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-vector-icons": "^9.0.0",
|
||||
"react-native-webview": "^13.2.2",
|
||||
"react-native-webview": "^13.10.5",
|
||||
"react-native-zip-archive": "^7.0.0",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"urlencode": "^2.0.0"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,27 @@ let data =
|
||||
os.EOL +
|
||||
`RELEASE_DATE=${formattedDate}`;
|
||||
|
||||
let existingEnvData = '';
|
||||
|
||||
fs.readFile(path.join(__dirname, '..', '.env'), 'utf8', (err, existingData) => {
|
||||
if (err) return;
|
||||
|
||||
existingEnvData = existingData
|
||||
.split(os.EOL)
|
||||
.filter(line => {
|
||||
return (
|
||||
!line.startsWith('BUILD_TYPE=') &&
|
||||
!line.startsWith('GIT_HASH=') &&
|
||||
!line.startsWith('RELEASE_DATE=')
|
||||
);
|
||||
})
|
||||
.join(os.EOL);
|
||||
});
|
||||
|
||||
if (existingEnvData) {
|
||||
data += os.EOL + existingEnvData;
|
||||
}
|
||||
|
||||
fs.writeFile(path.join(__dirname, '..', '.env'), data, 'utf8', err => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -4,6 +4,7 @@ import ErrorBoundary from 'react-native-error-boundary';
|
||||
|
||||
import { Button, List } from '@components';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error;
|
||||
@@ -17,7 +18,9 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.mainCtn, { backgroundColor: theme.background }]}>
|
||||
<SafeAreaView
|
||||
style={[styles.mainCtn, { backgroundColor: theme.background }]}
|
||||
>
|
||||
<StatusBar translucent={true} backgroundColor="transparent" />
|
||||
<View style={styles.errorInfoCtn}>
|
||||
<Text style={[styles.errorTitle, { color: theme.onSurface }]}>
|
||||
@@ -48,12 +51,12 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
|
||||
style={styles.buttonCtn}
|
||||
mode="contained"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode | React.ReactElement;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
const AppErrorBoundary: React.FC<AppErrorBoundaryProps> = ({ children }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Ref, useCallback } from 'react';
|
||||
import React, { RefObject, useCallback, useRef } from 'react';
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
@@ -6,12 +6,20 @@ import {
|
||||
BottomSheetModalProps,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useBackHandler } from '@hooks/index';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
|
||||
interface BottomSheetProps extends Omit<BottomSheetModalProps, 'ref'> {
|
||||
bottomSheetRef: Ref<BottomSheetModal> | null;
|
||||
bottomSheetRef: RefObject<BottomSheetModalMethods> | null;
|
||||
}
|
||||
|
||||
const BottomSheet: React.FC<BottomSheetProps> = props => {
|
||||
const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
bottomSheetRef,
|
||||
children,
|
||||
onChange,
|
||||
...otherProps
|
||||
}) => {
|
||||
const indexRef = useRef<number>();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
@@ -23,15 +31,26 @@ const BottomSheet: React.FC<BottomSheetProps> = props => {
|
||||
),
|
||||
[],
|
||||
);
|
||||
useBackHandler(() => {
|
||||
if (typeof indexRef.current === 'number' && indexRef.current !== -1) {
|
||||
bottomSheetRef?.current?.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
ref={bottomSheetRef}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleComponent={null}
|
||||
containerStyle={{ paddingBottom: bottom }}
|
||||
{...props}
|
||||
onChange={index => {
|
||||
onChange?.(index);
|
||||
indexRef.current = index;
|
||||
}}
|
||||
{...otherProps}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
text: {
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonWrapper: {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { coverPlaceholderColor } from '../theme/colors';
|
||||
import color from 'color';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import { LibraryNovelInfo } from '@database/types';
|
||||
import { NovelInfo } from '@database/types';
|
||||
|
||||
interface ListViewProps {
|
||||
item: NovelItem | LibraryNovelInfo;
|
||||
item: NovelItem | NovelInfo;
|
||||
downloadBadge?: React.ReactNode;
|
||||
unreadBadge?: React.ReactNode;
|
||||
inLibraryBadge?: React.ReactNode;
|
||||
|
||||
@@ -53,7 +53,7 @@ interface INovelCover<TNovel> {
|
||||
libraryStatus: boolean;
|
||||
theme: ThemeColors;
|
||||
isSelected: boolean;
|
||||
addSkeletonLoading: boolean;
|
||||
addSkeletonLoading?: boolean;
|
||||
onLongPress: (item: TNovel) => void;
|
||||
selectedNovelIds: number[];
|
||||
}
|
||||
@@ -86,8 +86,7 @@ function NovelCover<TNovel extends CoverItemLibrary | CoverItemPlugin>({
|
||||
|
||||
const coverHeight = useMemo(
|
||||
() => (window.width / numColumns) * (4 / 3),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[numColumns],
|
||||
[window.width, numColumns],
|
||||
);
|
||||
|
||||
const selectNovel = () => onLongPress(item);
|
||||
@@ -112,9 +111,9 @@ function NovelCover<TNovel extends CoverItemLibrary | CoverItemPlugin>({
|
||||
},
|
||||
styles.standardNovelCover,
|
||||
isSelected && {
|
||||
backgroundColor: theme.primary,
|
||||
} &&
|
||||
styles.selectedNovelCover,
|
||||
backgroundColor: theme.primary,
|
||||
...styles.selectedNovelCover,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
|
||||
@@ -52,6 +52,7 @@ export const ThemePicker = ({
|
||||
elevation: 2,
|
||||
borderRadius: 50,
|
||||
padding: 1.6,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { createRepositoryTableQuery } from './tables/RepositoryTable';
|
||||
|
||||
const dbName = 'lnreader.db';
|
||||
|
||||
const db = SQLite.openDatabase(dbName);
|
||||
export const db = SQLite.openDatabase(dbName);
|
||||
|
||||
export const createTables = () => {
|
||||
db.exec([{ sql: 'PRAGMA foreign_keys = ON', args: [] }], false, () => {});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { noop } from 'lodash-es';
|
||||
import { BackupCategory, Category, NovelCategory, CCategory } from '../types';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
import { getString } from '@strings/translations';
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
import { db } from '@database/db';
|
||||
|
||||
const getCategoriesQuery = `
|
||||
SELECT * FROM Category ORDER BY sort
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { ChapterInfo, DownloadedChapter } from '../types';
|
||||
import { ChapterItem } from '@plugins/types';
|
||||
@@ -9,8 +8,8 @@ import { noop } from 'lodash-es';
|
||||
import { getString } from '@strings/translations';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { db } from '@database/db';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
const insertChapterQuery = `
|
||||
INSERT OR IGNORE INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -42,7 +41,7 @@ export const insertChapters = async (
|
||||
`
|
||||
UPDATE Chapter SET
|
||||
page = ?, position = ?
|
||||
WHERE path = ? AND novelId = ? (AND page != ? OR position != ?)
|
||||
WHERE path = ? AND novelId = ? AND (page != ? OR position != ?)
|
||||
`,
|
||||
[
|
||||
chapter.page || '1',
|
||||
@@ -195,6 +194,17 @@ export const markChapterRead = async (chapterId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const markChaptersRead = async (chapterIds: number[]) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
`UPDATE Chapter SET \`unread\` = 0 WHERE id IN (${chapterIds.join(',')})`,
|
||||
undefined,
|
||||
noop,
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const markChapterUnreadQuery = 'UPDATE Chapter SET `unread` = 1 WHERE id = ?';
|
||||
|
||||
export const markChapterUnread = async (chapterId: number) => {
|
||||
@@ -203,6 +213,17 @@ export const markChapterUnread = async (chapterId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const markChaptersUnread = async (chapterIds: number[]) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
`UPDATE Chapter SET \`unread\` = 1 WHERE id IN (${chapterIds.join(',')})`,
|
||||
undefined,
|
||||
noop,
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const markAllChaptersReadQuery =
|
||||
'UPDATE Chapter SET `unread` = 0 WHERE novelId = ?';
|
||||
|
||||
@@ -342,6 +363,18 @@ export const updateChapterProgress = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const updateChapterProgressByIds = async (
|
||||
chapterIds: number[],
|
||||
progress: number,
|
||||
) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
`UPDATE Chapter SET progress = ? WHERE id in (${chapterIds.join(',')})`,
|
||||
[progress],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const bookmarkChapterQuery =
|
||||
'UPDATE Chapter SET bookmark = (CASE WHEN bookmark = 0 THEN 1 ELSE 0 END) WHERE id = ?';
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { History } from '@database/types';
|
||||
import { txnErrorCallback } from '@database/utils/helpers';
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { db } from '@database/db';
|
||||
import { noop } from 'lodash-es';
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
import { showToast } from '../../utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { LibraryFilter } from '@screens/library/constants/constants';
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { LibraryNovelInfo, NovelInfo } from '../types';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
import { db } from '@database/db';
|
||||
|
||||
export const getNovelsWithCategory = (
|
||||
categoryId: number,
|
||||
@@ -96,6 +94,8 @@ export const getLibraryWithCategory = ({
|
||||
downloadedOnlyMode?: boolean;
|
||||
}): Promise<LibraryNovelInfo[]> => {
|
||||
let query = getLibraryWithCategoryQuery;
|
||||
let preparedArgument: (string | number | null)[] = [];
|
||||
|
||||
if (filter) {
|
||||
query += ` AND ${filter} `;
|
||||
}
|
||||
@@ -104,17 +104,19 @@ export const getLibraryWithCategory = ({
|
||||
}
|
||||
|
||||
if (searchText) {
|
||||
query += ` AND name LIKE '%${searchText}%' `;
|
||||
query += ' AND name LIKE ? ';
|
||||
preparedArgument.push(`%${searchText}%`);
|
||||
}
|
||||
|
||||
if (sortOrder) {
|
||||
query += ` ORDER BY ${sortOrder} `;
|
||||
}
|
||||
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
query,
|
||||
[],
|
||||
preparedArgument,
|
||||
(txObj, { rows }) => resolve((rows as any)._array),
|
||||
txnErrorCallback,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
|
||||
import { fetchNovel } from '@services/plugin/fetch';
|
||||
@@ -16,6 +13,7 @@ import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { downloadFile } from '@plugins/helpers/fetch';
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { db } from '@database/db';
|
||||
|
||||
export const insertNovelAndChapters = async (
|
||||
pluginId: string,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
import { Repository } from '@database/types';
|
||||
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
import { db } from '@database/db';
|
||||
|
||||
const getRepositoriesQuery = 'SELECT * FROM Repository';
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { countBy } from 'lodash-es';
|
||||
import { LibraryStats } from '../types';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
import { db } from '@database/db';
|
||||
|
||||
const getLibraryStatsQuery = `
|
||||
SELECT COUNT(*) as novelsCount, COUNT(DISTINCT pluginId) as sourcesCount
|
||||
|
||||
@@ -42,7 +42,7 @@ const useFullscreenMode = () => {
|
||||
* But in hexa, ##xxxxxx00 could be another color
|
||||
*/
|
||||
changeNavigationBarColor(
|
||||
Color(theme.surface).alpha(0.05).hexa(),
|
||||
Color(theme.surface).alpha(0.01).hexa(),
|
||||
theme.isDark,
|
||||
);
|
||||
setStatusBarColor(theme);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const useSearch = (defaultSearchText?: string) => {
|
||||
const useSearch = (defaultSearchText?: string, clearSearchOnUnfocus = true) => {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
const [searchText, setSearchText] = useState<string>(defaultSearchText || '');
|
||||
@@ -9,8 +9,10 @@ const useSearch = (defaultSearchText?: string) => {
|
||||
const clearSearchbar = useCallback(() => setSearchText(''), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
clearSearchbar();
|
||||
if (clearSearchOnUnfocus) {
|
||||
if (!isFocused) {
|
||||
clearSearchbar();
|
||||
}
|
||||
}
|
||||
}, [isFocused, clearSearchbar]);
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
bookmarkChapter as _bookmarkChapter,
|
||||
markChapterRead as _markChapterRead,
|
||||
markChaptersRead as _markChaptersRead,
|
||||
markPreviuschaptersRead as _markPreviuschaptersRead,
|
||||
markPreviousChaptersUnread as _markPreviousChaptersUnread,
|
||||
markChapterUnread as _markChapterUnread,
|
||||
markChaptersUnread as _markChaptersUnread,
|
||||
deleteChapter as _deleteChapter,
|
||||
deleteChapters as _deleteChapters,
|
||||
getPageChapters as _getPageChapters,
|
||||
@@ -30,6 +31,7 @@ import dayjs from 'dayjs';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { useAppSettings } from './useSettings';
|
||||
|
||||
// store key: '<PREFIX>_<novel.pluginId>_<novel.path>',
|
||||
// store key: '<PREFIX>_<novel.id>',
|
||||
@@ -145,6 +147,10 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
`${NOVEL_SETTINSG_PREFIX}_${pluginId}_${novelPath}`,
|
||||
);
|
||||
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
|
||||
const sort = novelSettings.sort || defaultChapterSort;
|
||||
|
||||
const openPage = useCallback((index: number) => {
|
||||
setPageIndex(index);
|
||||
}, []);
|
||||
@@ -153,12 +159,12 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
if (novel) {
|
||||
_getPageChapters(
|
||||
novel.id,
|
||||
novelSettings.sort,
|
||||
sort,
|
||||
novelSettings.filter,
|
||||
pages[pageIndex],
|
||||
).then(chapters => setChapters(chapters));
|
||||
}
|
||||
}, [novel, pageIndex]);
|
||||
}, [novel, pageIndex, sort, novelSettings]);
|
||||
|
||||
const sortAndFilterChapters = async (sort?: string, filter?: string) => {
|
||||
if (novel) {
|
||||
@@ -229,10 +235,12 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
};
|
||||
|
||||
const markChaptersRead = (_chapters: ChapterInfo[]) => {
|
||||
_chapters.map(chapter => _markChapterRead(chapter.id));
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
_markChaptersRead(chapterIds);
|
||||
|
||||
setChapters(
|
||||
chapters.map(chapter => {
|
||||
if (_chapters.some(c => c.id === chapter.id)) {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: false,
|
||||
@@ -255,10 +263,12 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
};
|
||||
|
||||
const markChaptersUnread = (_chapters: ChapterInfo[]) => {
|
||||
_chapters.map(chapter => _markChapterUnread(chapter.id));
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
_markChaptersUnread(chapterIds);
|
||||
|
||||
setChapters(
|
||||
chapters.map(chapter => {
|
||||
if (_chapters.some(c => c.id === chapter.id)) {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: true,
|
||||
@@ -340,6 +350,7 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
setPages(['1']);
|
||||
}
|
||||
setNovel(novel);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const getChapters = useCallback(async () => {
|
||||
@@ -347,7 +358,7 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
if (novel && page) {
|
||||
let chapters = await _getPageChapters(
|
||||
novel.id,
|
||||
novelSettings.sort,
|
||||
sort,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
);
|
||||
@@ -362,18 +373,19 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
await insertChapters(novel.id, sourceChapters);
|
||||
chapters = await _getPageChapters(
|
||||
novel.id,
|
||||
novelSettings.sort,
|
||||
sort,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
);
|
||||
}
|
||||
setChapters(chapters);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [novel, novelSettings, pageIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
getNovel();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getChapters().catch(e => showToast(e.message));
|
||||
}, [getChapters]);
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function usePlugins() {
|
||||
const actualPlugin: PluginItem = {
|
||||
...plugin,
|
||||
version: _plg.version,
|
||||
hasSettings: !!_plg.pluginSettings,
|
||||
};
|
||||
// safe
|
||||
if (!installedPlugins.some(plg => plg.id === plugin.id)) {
|
||||
@@ -138,7 +139,7 @@ export default function usePlugins() {
|
||||
|
||||
const updatePlugin = (plugin: PluginItem) => {
|
||||
return _update(plugin).then(_plg => {
|
||||
if (plugin.version === _plg?.version) {
|
||||
if (plugin.version === _plg?.version && !__DEV__) {
|
||||
throw new Error('No update found!');
|
||||
}
|
||||
if (_plg) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AppSettings {
|
||||
showUpdatesTab: boolean;
|
||||
showLabelsInNav: boolean;
|
||||
useFabForContinueReading: boolean;
|
||||
disableLoadingAnimations: boolean;
|
||||
|
||||
/**
|
||||
* Library settings
|
||||
@@ -56,6 +57,7 @@ export interface AppSettings {
|
||||
export interface BrowseSettings {
|
||||
showMyAnimeList: boolean;
|
||||
showAniList: boolean;
|
||||
globalSearchConcurrency?: number;
|
||||
}
|
||||
|
||||
export interface LibrarySettings {
|
||||
@@ -72,7 +74,9 @@ export interface LibrarySettings {
|
||||
}
|
||||
|
||||
export interface ChapterGeneralSettings {
|
||||
keepScreenOn: boolean;
|
||||
fullScreenMode: boolean;
|
||||
pageReader: boolean;
|
||||
swipeGestures: boolean;
|
||||
showScrollPercentage: boolean;
|
||||
useVolumeButtons: boolean;
|
||||
@@ -83,6 +87,8 @@ export interface ChapterGeneralSettings {
|
||||
verticalSeekbar: boolean;
|
||||
removeExtraParagraphSpacing: boolean;
|
||||
bionicReading: boolean;
|
||||
tapToScroll: boolean;
|
||||
TTSEnable: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderTheme {
|
||||
@@ -106,6 +112,10 @@ export interface ChapterReaderSettings {
|
||||
rate?: number;
|
||||
pitch?: number;
|
||||
};
|
||||
epubLocation: string;
|
||||
epubUseAppTheme: boolean;
|
||||
epubUseCustomCSS: boolean;
|
||||
epubUseCustomJS: boolean;
|
||||
}
|
||||
|
||||
const initialAppSettings: AppSettings = {
|
||||
@@ -124,6 +134,7 @@ const initialAppSettings: AppSettings = {
|
||||
showUpdatesTab: true,
|
||||
showLabelsInNav: true,
|
||||
useFabForContinueReading: false,
|
||||
disableLoadingAnimations: false,
|
||||
|
||||
/**
|
||||
* Library settings
|
||||
@@ -152,10 +163,13 @@ const initialAppSettings: AppSettings = {
|
||||
const initialBrowseSettings: BrowseSettings = {
|
||||
showMyAnimeList: true,
|
||||
showAniList: true,
|
||||
globalSearchConcurrency: 3,
|
||||
};
|
||||
|
||||
export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
keepScreenOn: true,
|
||||
fullScreenMode: true,
|
||||
pageReader: false,
|
||||
swipeGestures: false,
|
||||
showScrollPercentage: true,
|
||||
useVolumeButtons: false,
|
||||
@@ -166,6 +180,8 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
verticalSeekbar: true,
|
||||
removeExtraParagraphSpacing: false,
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
@@ -173,7 +189,7 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
textColor: '#CCCCCC',
|
||||
textSize: 16,
|
||||
textAlign: 'left',
|
||||
padding: 2,
|
||||
padding: 16,
|
||||
fontFamily: '',
|
||||
lineHeight: 1.5,
|
||||
customCSS: '',
|
||||
@@ -183,6 +199,10 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
},
|
||||
epubLocation: '',
|
||||
epubUseAppTheme: false,
|
||||
epubUseCustomCSS: false,
|
||||
epubUseCustomJS: false,
|
||||
};
|
||||
|
||||
export const useAppSettings = () => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { lazy, useMemo } from 'react';
|
||||
import { createMaterialBottomTabNavigator } from 'react-native-paper/react-navigation';
|
||||
|
||||
import Library from '../screens/library/LibraryScreen';
|
||||
import Updates from '../screens/updates/UpdatesScreen';
|
||||
import History from '../screens/history/HistoryScreen';
|
||||
import Browse from '../screens/browse/BrowseScreen';
|
||||
import More from '../screens/more/MoreScreen';
|
||||
const Updates = lazy(() => import('../screens/updates/UpdatesScreen'));
|
||||
const History = lazy(() => import('../screens/history/HistoryScreen'));
|
||||
const Browse = lazy(() => import('../screens/browse/BrowseScreen'));
|
||||
const More = lazy(() => import('../screens/more/MoreScreen'));
|
||||
|
||||
import { getString } from '@strings/translations';
|
||||
import { useAppSettings, useTheme } from '@hooks/persisted';
|
||||
import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted';
|
||||
import { BottomNavigatorParamList } from './types';
|
||||
|
||||
const Tab = createMaterialBottomTabNavigator<BottomNavigatorParamList>();
|
||||
@@ -22,6 +22,12 @@ const BottomNavigator = () => {
|
||||
showLabelsInNav = false,
|
||||
} = useAppSettings();
|
||||
|
||||
const { filteredInstalledPlugins } = usePlugins();
|
||||
const pluginsWithUpdate = useMemo(
|
||||
() => filteredInstalledPlugins.filter(p => p.hasUpdate).length,
|
||||
[filteredInstalledPlugins],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
barStyle={{ backgroundColor: theme.surface2 }}
|
||||
@@ -63,6 +69,9 @@ const BottomNavigator = () => {
|
||||
options={{
|
||||
title: getString('browse'),
|
||||
tabBarIcon: 'compass-outline',
|
||||
tabBarBadge: pluginsWithUpdate
|
||||
? pluginsWithUpdate.toString()
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
|
||||
+32
-13
@@ -15,24 +15,42 @@ import { useGithubUpdateChecker } from '@hooks/common/githubUpdateChecker';
|
||||
* Navigators
|
||||
*/
|
||||
import BottomNavigator from './BottomNavigator';
|
||||
import MoreStack from './MoreStack';
|
||||
const MoreStack = React.lazy(() => import('./MoreStack'));
|
||||
|
||||
/**
|
||||
* Screens
|
||||
*/
|
||||
import Novel from '../screens/novel/NovelScreen';
|
||||
import Reader from '../screens/reader/ReaderScreen';
|
||||
import BrowseSourceScreen from '../screens/BrowseSourceScreen/BrowseSourceScreen';
|
||||
import GlobalSearchScreen from '../screens/GlobalSearchScreen/GlobalSearchScreen';
|
||||
import Migration from '../screens/browse/migration/Migration';
|
||||
import SourceNovels from '../screens/browse/SourceNovels';
|
||||
import MigrateNovel from '../screens/browse/migration/MigrationNovels';
|
||||
const Novel = React.lazy(() => import('../screens/novel/NovelScreen'));
|
||||
const Reader = React.lazy(() => import('../screens/reader/ReaderScreen'));
|
||||
const BrowseSourceScreen = React.lazy(
|
||||
() => import('../screens/BrowseSourceScreen/BrowseSourceScreen'),
|
||||
);
|
||||
const GlobalSearchScreen = React.lazy(
|
||||
() => import('../screens/GlobalSearchScreen/GlobalSearchScreen'),
|
||||
);
|
||||
const Migration = React.lazy(
|
||||
() => import('../screens/browse/migration/Migration'),
|
||||
);
|
||||
const SourceNovels = React.lazy(() => import('../screens/browse/SourceNovels'));
|
||||
const MigrateNovel = React.lazy(
|
||||
() => import('../screens/browse/migration/MigrationNovels'),
|
||||
);
|
||||
|
||||
import MalTopNovels from '../screens/browse/discover/MalTopNovels';
|
||||
import AniListTopNovels from '../screens/browse/discover/AniListTopNovels';
|
||||
import NewUpdateDialog from '../components/NewUpdateDialog';
|
||||
import BrowseSettings from '../screens/browse/BrowseSettings';
|
||||
import WebviewScreen from '@screens/WebviewScreen/WebviewScreen';
|
||||
const MalTopNovels = React.lazy(
|
||||
() => import('../screens/browse/discover/MalTopNovels'),
|
||||
);
|
||||
const AniListTopNovels = React.lazy(
|
||||
() => import('../screens/browse/discover/AniListTopNovels'),
|
||||
);
|
||||
const NewUpdateDialog = React.lazy(
|
||||
() => import('../components/NewUpdateDialog'),
|
||||
);
|
||||
const BrowseSettings = React.lazy(
|
||||
() => import('../screens/browse/settings/BrowseSettings'),
|
||||
);
|
||||
const WebviewScreen = React.lazy(
|
||||
() => import('@screens/WebviewScreen/WebviewScreen'),
|
||||
);
|
||||
import { RootStackParamList } from './types';
|
||||
import Color from 'color';
|
||||
import { useMMKVBoolean } from 'react-native-mmkv';
|
||||
@@ -98,6 +116,7 @@ const MainNavigator = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<NavigationContainer theme={{ colors: theme, dark: theme.isDark }}>
|
||||
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import ReaderSettings from '../screens/settings/SettingsReaderScreen/SettingsRea
|
||||
import BackupSettings from '../screens/settings/SettingsBackupScreen';
|
||||
import AdvancedSettings from '../screens/settings/SettingsAdvancedScreen';
|
||||
import GeneralSettings from '../screens/settings/SettingsGeneralScreen/SettingsGeneralScreen';
|
||||
import DownloadQueue from '../screens/more/DownloadQueueScreen';
|
||||
import TaskQueue from '../screens/more/TaskQueueScreen';
|
||||
import Downloads from '../screens/more/DownloadsScreen';
|
||||
import AppearanceSettings from '../screens/settings/SettingsAppearanceScreen';
|
||||
import CategoriesScreen from '@screens/Categories/CategoriesScreen';
|
||||
@@ -43,7 +43,7 @@ const MoreStack = () => (
|
||||
<Stack.Navigator screenOptions={stackNavigatorConfig}>
|
||||
<Stack.Screen name="SettingsStack" component={SettingsStack} />
|
||||
<Stack.Screen name="About" component={About} />
|
||||
<Stack.Screen name="DownloadQueue" component={DownloadQueue} />
|
||||
<Stack.Screen name="TaskQueue" component={TaskQueue} />
|
||||
<Stack.Screen name="Downloads" component={Downloads} />
|
||||
<Stack.Screen name="Categories" component={CategoriesScreen} />
|
||||
<Stack.Screen name="Statistics" component={StatsScreen} />
|
||||
|
||||
@@ -53,6 +53,11 @@ export type HistoryScreenProps = CompositeScreenProps<
|
||||
StackScreenProps<RootStackParamList>
|
||||
>;
|
||||
|
||||
export type UpdateScreenProps = CompositeScreenProps<
|
||||
MaterialBottomTabScreenProps<BottomNavigatorParamList, 'Updates'>,
|
||||
StackScreenProps<RootStackParamList>
|
||||
>;
|
||||
|
||||
export type BrowseScreenProps = CompositeScreenProps<
|
||||
MaterialBottomTabScreenProps<BottomNavigatorParamList, 'Browse'>,
|
||||
StackScreenProps<RootStackParamList>
|
||||
@@ -65,7 +70,7 @@ export type MoreStackScreenProps = CompositeScreenProps<
|
||||
export type MoreStackParamList = {
|
||||
SettingsStack: NavigatorScreenParams<SettingsStackParamList>;
|
||||
About: undefined;
|
||||
DownloadQueue: undefined;
|
||||
TaskQueue: undefined;
|
||||
Downloads: undefined;
|
||||
Categories: undefined;
|
||||
Statistics: undefined;
|
||||
@@ -94,9 +99,9 @@ export type DownloadsScreenProps = StackScreenProps<
|
||||
MoreStackParamList,
|
||||
'Downloads'
|
||||
>;
|
||||
export type DownloadQueueScreenProps = StackScreenProps<
|
||||
export type TaskQueueScreenProps = StackScreenProps<
|
||||
MoreStackParamList,
|
||||
'DownloadQueue'
|
||||
'TaskQueue'
|
||||
>;
|
||||
export type BrowseSourceScreenProps = StackScreenProps<
|
||||
RootStackParamList,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { store } from './helpers/storage';
|
||||
// packages for plugins
|
||||
import { load } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
import qs from 'qs';
|
||||
import { NovelStatus, Plugin, PluginItem } from './types';
|
||||
import { FilterTypes } from './types/filterTypes';
|
||||
import { isUrlAbsolute } from './helpers/isAbsoluteUrl';
|
||||
@@ -23,7 +22,6 @@ const packages: Record<string, any> = {
|
||||
'htmlparser2': { Parser },
|
||||
'cheerio': { load },
|
||||
'dayjs': dayjs,
|
||||
'qs': qs,
|
||||
'urlencode': { encode, decode },
|
||||
'@libs/novelStatus': { NovelStatus },
|
||||
'@libs/fetch': { fetchApi, fetchText, fetchProto },
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface PluginItem {
|
||||
customJS?: string;
|
||||
customCSS?: string;
|
||||
hasUpdate?: boolean;
|
||||
hasSettings?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageRequestInit {
|
||||
@@ -68,6 +69,7 @@ export interface ImageRequestInit {
|
||||
export interface Plugin extends PluginItem {
|
||||
imageRequestInit?: ImageRequestInit;
|
||||
filters?: Filters;
|
||||
pluginSettings: any;
|
||||
popularNovels: (
|
||||
pageNo: number,
|
||||
options?: PopularNovelsOptions<Filters>,
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import BottomSheet from '@components/BottomSheet/BottomSheet';
|
||||
import { BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet';
|
||||
import {
|
||||
BottomSheetFlatList,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import {
|
||||
@@ -16,7 +20,6 @@ import {
|
||||
FilterToValues,
|
||||
Filters,
|
||||
} from '@plugins/types/filterTypes';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { Button } from '@components/index';
|
||||
import { Checkbox } from '@components/Checkbox/Checkbox';
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
@@ -25,6 +28,8 @@ import { Menu, TextInput, overlay } from 'react-native-paper';
|
||||
import { getValueFor } from './filterUtils';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Switch from '@components/Switch/Switch';
|
||||
|
||||
const insertOrRemoveIntoArray = (array: string[], val: string): string[] =>
|
||||
array.indexOf(val) > -1 ? array.filter(ele => ele !== val) : [...array, val];
|
||||
@@ -52,11 +57,51 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
setFalse: closeCard,
|
||||
} = useBoolean();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
if (filter.type === FilterTypes.TextInput) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
return (
|
||||
<View style={styles.textContainer}>
|
||||
<TextInput
|
||||
style={{ flex: 1, width: screenWidth - 48 }}
|
||||
mode="outlined"
|
||||
label={
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: theme.onSurface,
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{` ${filter.label} `}
|
||||
</Text>
|
||||
}
|
||||
defaultValue={value}
|
||||
theme={{ colors: { background: 'transparent' } }}
|
||||
outlineColor={theme.onSurface}
|
||||
textColor={theme.onSurface}
|
||||
onChangeText={text =>
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
[filterKey]: { value: text, type: FilterTypes.TextInput },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filter.type === FilterTypes.Picker) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
const label =
|
||||
filter.options.find(option => option.value === value)?.label ||
|
||||
'whatever';
|
||||
return (
|
||||
<View style={styles.pickerContainer}>
|
||||
<Menu
|
||||
@@ -76,24 +121,18 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
styles.label,
|
||||
{
|
||||
color: isVisible ? theme.primary : theme.onSurface,
|
||||
backgroundColor: theme.surface,
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{` ${filter.label} `}
|
||||
</Text>
|
||||
}
|
||||
value={value || 'whatever'}
|
||||
value={label}
|
||||
editable={false}
|
||||
theme={{ colors: { background: 'transparent' } }}
|
||||
outlineColor={isVisible ? theme.primary : theme.onSurface}
|
||||
textColor={isVisible ? theme.primary : theme.onSurface}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={isVisible ? 'chevron-up' : 'chevron-down'}
|
||||
color={isVisible ? theme.primary : theme.onSurface}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
@@ -164,6 +203,42 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filter.type === FilterTypes.Switch) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
selectedFilters[filterKey],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
style={[styles.container]}
|
||||
onPress={() => {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
[filterKey]: { value: !value, type: FilterTypes.Switch },
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<View style={styles.switchContainer}>
|
||||
<View style={styles.switchLabelContainer}>
|
||||
<Text style={[{ color: theme.onSurface }, styles.switchLabel]}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={() => {
|
||||
setSelectedFilters(prevState => ({
|
||||
...prevState,
|
||||
[filterKey]: { value: !value, type: FilterTypes.Switch },
|
||||
}));
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
if (filter.type === FilterTypes.ExcludableCheckboxGroup) {
|
||||
const value = getValueFor<(typeof filter)['type']>(
|
||||
filter,
|
||||
@@ -273,53 +348,51 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
|
||||
setFilters,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<SelectedFilters>(filters);
|
||||
|
||||
return (
|
||||
<BottomSheet bottomSheetRef={filterSheetRef} snapPoints={[400, 600]}>
|
||||
<BottomSheet
|
||||
bottomSheetRef={filterSheetRef}
|
||||
snapPoints={[400, 600]}
|
||||
bottomInset={bottom}
|
||||
backgroundStyle={{ backgroundColor: 'transparent' }}
|
||||
style={[styles.container, { backgroundColor: overlay(2, theme.surface) }]}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
style={[styles.buttonContainer, { borderBottomColor: theme.outline }]}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={[styles.buttonContainer, { borderBottomColor: theme.outline }]}
|
||||
>
|
||||
<Button
|
||||
title={getString('common.reset')}
|
||||
onPress={() => {
|
||||
setSelectedFilters(filters);
|
||||
clearFilters(filters);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.filter')}
|
||||
textColor={theme.onPrimary}
|
||||
onPress={() => {
|
||||
setFilters(selectedFilters);
|
||||
filterSheetRef?.current?.close();
|
||||
}}
|
||||
mode="contained"
|
||||
/>
|
||||
</BottomSheetView>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.filterContainer}
|
||||
data={filters && Object.entries(filters)}
|
||||
keyExtractor={item => 'filter' + item[0]}
|
||||
renderItem={({ item }) => (
|
||||
<FilterItem
|
||||
theme={theme}
|
||||
filter={item[1]}
|
||||
filterKey={item[0]}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
title={getString('common.reset')}
|
||||
onPress={() => {
|
||||
setSelectedFilters(filters);
|
||||
clearFilters(filters);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.filter')}
|
||||
textColor={theme.onPrimary}
|
||||
onPress={() => {
|
||||
setFilters(selectedFilters);
|
||||
filterSheetRef?.current?.close();
|
||||
}}
|
||||
mode="contained"
|
||||
/>
|
||||
</BottomSheetView>
|
||||
<BottomSheetFlatList
|
||||
data={filters && Object.entries(filters)}
|
||||
keyExtractor={item => 'filter' + item[0]}
|
||||
renderItem={({ item }) => (
|
||||
<FilterItem
|
||||
theme={theme}
|
||||
filter={item[1]}
|
||||
filterKey={item[0]}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
@@ -340,13 +413,30 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingBottom: 6,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingVertical: 16,
|
||||
switchLabelContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: 16,
|
||||
},
|
||||
switchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
textContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
pickerContainer: {
|
||||
@@ -355,14 +445,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
picker: {
|
||||
width: 200,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
checkboxHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { StyleSheet, View } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
@@ -12,9 +13,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const CategorySkeletonLoading: React.FC<Props> = ({ height, width, theme }) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const renderLoadingCard = (item: number, index: number) => {
|
||||
return (
|
||||
@@ -24,6 +26,7 @@ const CategorySkeletonLoading: React.FC<Props> = ({ height, width, theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={height}
|
||||
width={width}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ const GlobalSearchScreen = (props: Props) => {
|
||||
const theme = useTheme();
|
||||
const { searchText, setSearchText, clearSearchbar } = useSearch(
|
||||
props?.route?.params?.searchText,
|
||||
false,
|
||||
);
|
||||
const onChangeText = (text: string) => setSearchText(text);
|
||||
const onSubmitEditing = () => globalSearch(searchText);
|
||||
|
||||
@@ -13,8 +13,9 @@ import { GlobalSearchResult } from '../hooks/useGlobalSearch';
|
||||
import GlobalSearchNovelItem from './GlobalSearchNovelItem';
|
||||
import { useLibraryNovels } from '@screens/library/hooks/useLibrary';
|
||||
import { LibraryNovelInfo } from '@database/types';
|
||||
import GlobalSearchSkeletonLoading from '@screens/browse/loadingAnimation/GlobalSearchSkeletonLoading';
|
||||
import { switchNovelToLibrary } from '@database/queries/NovelQueries';
|
||||
import GlobalSearchSkeletonLoading from '@screens/browse/loadingAnimation/GlobalSearchSkeletonLoading';
|
||||
import { interpolateColor } from 'react-native-reanimated';
|
||||
|
||||
interface GlobalSearchResultsListProps {
|
||||
searchResults: GlobalSearchResult[];
|
||||
@@ -25,133 +26,141 @@ const GlobalSearchResultsList: React.FC<GlobalSearchResultsListProps> = ({
|
||||
searchResults,
|
||||
ListEmptyComponent,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<StackNavigationProp<any>>();
|
||||
const keyExtractor = useCallback(
|
||||
(item: GlobalSearchResult) => item.plugin.id,
|
||||
[],
|
||||
);
|
||||
const { library, setLibrary } = useLibraryNovels();
|
||||
|
||||
const novelInLibrary = (pluginId: string, novelPath: string) =>
|
||||
library?.some(
|
||||
novel => novel.pluginId === pluginId && novel.path === novelPath,
|
||||
);
|
||||
|
||||
const errorColor = useMemo(() => (theme.isDark ? '#B3261E' : '#F2B8B5'), []);
|
||||
|
||||
const navigateToNovel = useCallback(
|
||||
(item: { name: string; path: string; pluginId: string }) =>
|
||||
navigation.push('Novel', item),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList<GlobalSearchResult>
|
||||
keyExtractor={keyExtractor}
|
||||
data={searchResults}
|
||||
contentContainerStyle={styles.resultList}
|
||||
renderItem={({ item }) => (
|
||||
<>
|
||||
<View>
|
||||
<Pressable
|
||||
android_ripple={{
|
||||
color: color(theme.primary).alpha(0.12).string(),
|
||||
}}
|
||||
style={styles.sourceHeader}
|
||||
onPress={() =>
|
||||
navigation.navigate('SourceScreen', {
|
||||
pluginId: item.plugin.id,
|
||||
pluginName: item.plugin.name,
|
||||
site: item.plugin.site,
|
||||
})
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.sourceName, { color: theme.onSurface }]}>
|
||||
{item.plugin.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.language, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{item.plugin.lang}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="arrow-right"
|
||||
size={24}
|
||||
color={theme.onSurface}
|
||||
/>
|
||||
</Pressable>
|
||||
{item.isLoading ? (
|
||||
<GlobalSearchSkeletonLoading theme={theme} />
|
||||
) : item.error ? (
|
||||
<Text style={[styles.error, { color: errorColor }]}>
|
||||
{item.error}
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
horizontal
|
||||
contentContainerStyle={styles.novelsContainer}
|
||||
keyExtractor={novelItem =>
|
||||
item.plugin.id + '_' + novelItem.path
|
||||
}
|
||||
data={item.novels}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={[
|
||||
styles.listEmpty,
|
||||
{ color: theme.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{getString('sourceScreen.noResultsFound')}
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: novelItem }) => {
|
||||
const inLibrary = novelInLibrary(
|
||||
item.plugin.id,
|
||||
novelItem.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<GlobalSearchNovelItem
|
||||
novel={novelItem}
|
||||
pluginId={item.plugin.id}
|
||||
inLibrary={inLibrary}
|
||||
navigateToNovel={navigateToNovel}
|
||||
theme={theme}
|
||||
onLongPress={() => {
|
||||
setLibrary(prevValues => {
|
||||
if (inLibrary) {
|
||||
return [
|
||||
...prevValues.filter(
|
||||
novel => novel.path !== novelItem.path,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
...prevValues,
|
||||
{
|
||||
path: novelItem.path,
|
||||
} as LibraryNovelInfo,
|
||||
];
|
||||
}
|
||||
});
|
||||
switchNovelToLibrary(novelItem.path, item.plugin.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
renderItem={({ item }) => <GlobalSearchSourceResults item={item} />}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
|
||||
item,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<StackNavigationProp<any>>();
|
||||
const { library, setLibrary } = useLibraryNovels();
|
||||
|
||||
const novelInLibrary = (pluginId: string, novelPath: string) =>
|
||||
library?.some(
|
||||
novel => novel.pluginId === pluginId && novel.path === novelPath,
|
||||
);
|
||||
|
||||
const errorColor = theme.isDark ? '#B3261E' : '#F2B8B5';
|
||||
const noResultsColor = interpolateColor(
|
||||
0.8,
|
||||
[0, 1],
|
||||
['transparent', theme.onSurfaceVariant],
|
||||
);
|
||||
|
||||
const navigateToNovel = useCallback(
|
||||
(item: { name: string; path: string; pluginId: string }) =>
|
||||
navigation.push('Novel', item),
|
||||
[],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<View>
|
||||
<Pressable
|
||||
android_ripple={{
|
||||
color: color(theme.primary).alpha(0.12).string(),
|
||||
}}
|
||||
style={styles.sourceHeader}
|
||||
onPress={() =>
|
||||
navigation.navigate('SourceScreen', {
|
||||
pluginId: item.plugin.id,
|
||||
pluginName: item.plugin.name,
|
||||
site: item.plugin.site,
|
||||
})
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.sourceName, { color: theme.onSurface }]}>
|
||||
{item.plugin.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.language, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{item.plugin.lang}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="arrow-right"
|
||||
size={24}
|
||||
color={theme.onSurface}
|
||||
/>
|
||||
</Pressable>
|
||||
{item.isLoading ? (
|
||||
<GlobalSearchSkeletonLoading theme={theme} />
|
||||
) : item.error ? (
|
||||
<Text style={[styles.error, { color: errorColor }]}>
|
||||
{item.error}
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
horizontal
|
||||
contentContainerStyle={styles.novelsContainer}
|
||||
keyExtractor={novelItem => item.plugin.id + '_' + novelItem.path}
|
||||
data={item.novels}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.listEmpty, { color: noResultsColor }]}>
|
||||
{getString('sourceScreen.noResultsFound')}
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: novelItem }) => {
|
||||
const inLibrary = novelInLibrary(
|
||||
item.plugin.id,
|
||||
novelItem.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<GlobalSearchNovelItem
|
||||
novel={novelItem}
|
||||
pluginId={item.plugin.id}
|
||||
inLibrary={inLibrary}
|
||||
navigateToNovel={navigateToNovel}
|
||||
theme={theme}
|
||||
onLongPress={() => {
|
||||
setLibrary(prevValues => {
|
||||
if (inLibrary) {
|
||||
return [
|
||||
...prevValues.filter(
|
||||
novel => novel.path !== novelItem.path,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
...prevValues,
|
||||
{
|
||||
path: novelItem.path,
|
||||
} as LibraryNovelInfo,
|
||||
];
|
||||
}
|
||||
});
|
||||
switchNovelToLibrary(novelItem.path, item.plugin.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
),
|
||||
[item.isLoading],
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearchResultsList;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { NovelItem, PluginItem } from '@plugins/types';
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { usePlugins } from '@hooks/persisted';
|
||||
import { useBrowseSettings, usePlugins } from '@hooks/persisted';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
interface Props {
|
||||
defaultSearchText?: string;
|
||||
@@ -16,14 +17,35 @@ export interface GlobalSearchResult {
|
||||
}
|
||||
|
||||
export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
const isMounted = useRef(true);
|
||||
const isMounted = useRef(true); //if user closes the search screen, cancel the search
|
||||
const isFocused = useRef(true); //if the user opens a sub-screen (e.g. novel screen), pause the search
|
||||
const lastSearch = useRef(''); //if the user changes search, cancel running searches
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMounted.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isFocused.current = true;
|
||||
|
||||
return () => (isFocused.current = false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const { filteredInstalledPlugins } = usePlugins();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const { globalSearchConcurrency = 1 } = useBrowseSettings();
|
||||
|
||||
const globalSearch = (searchText: string) => {
|
||||
if (lastSearch.current === searchText) {
|
||||
return;
|
||||
}
|
||||
lastSearch.current = searchText;
|
||||
const defaultResult: GlobalSearchResult[] = filteredInstalledPlugins.map(
|
||||
plugin => ({
|
||||
isLoading: true,
|
||||
@@ -33,46 +55,33 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
}),
|
||||
);
|
||||
|
||||
setSearchResults(defaultResult);
|
||||
setSearchResults(defaultResult.sort(novelResultSorter));
|
||||
setProgress(0);
|
||||
|
||||
filteredInstalledPlugins.forEach(async _plugin => {
|
||||
if (isMounted.current) {
|
||||
try {
|
||||
const plugin = getPlugin(_plugin.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin: ${_plugin.id}`);
|
||||
}
|
||||
const res = await plugin.searchNovels(searchText, 1);
|
||||
let running = 0;
|
||||
|
||||
setSearchResults(prevState =>
|
||||
prevState.map(prevResult =>
|
||||
async function searchInPlugin(_plugin: PluginItem) {
|
||||
try {
|
||||
const plugin = getPlugin(_plugin.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin: ${_plugin.id}`);
|
||||
}
|
||||
const res = await plugin.searchNovels(searchText, 1);
|
||||
|
||||
setSearchResults(prevState =>
|
||||
prevState
|
||||
.map(prevResult =>
|
||||
prevResult.plugin.id === plugin.id
|
||||
? { ...prevResult, novels: res, isLoading: false }
|
||||
: { ...prevResult },
|
||||
),
|
||||
);
|
||||
|
||||
setSearchResults(prevState =>
|
||||
prevState.sort(
|
||||
(
|
||||
{ novels: a, plugin: { name: aName } },
|
||||
{ novels: b, plugin: { name: bName } },
|
||||
) => {
|
||||
if (!a.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aName.localeCompare(bName);
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || String(error);
|
||||
setSearchResults(prevState =>
|
||||
prevState.map(prevResult =>
|
||||
)
|
||||
.sort(novelResultSorter),
|
||||
);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || String(error);
|
||||
setSearchResults(prevState =>
|
||||
prevState
|
||||
.map(prevResult =>
|
||||
prevResult.plugin.id === _plugin.id
|
||||
? {
|
||||
...prevResult,
|
||||
@@ -81,15 +90,57 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
error: errorMessage,
|
||||
}
|
||||
: { ...prevResult },
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setProgress(
|
||||
prevState => prevState + 1 / filteredInstalledPlugins.length,
|
||||
);
|
||||
)
|
||||
.sort(novelResultSorter),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort so we load the plugins results in the same order as they show on the list
|
||||
let filteredSortedInstalledPlugins = [...filteredInstalledPlugins].sort(
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
(async () => {
|
||||
if (globalSearchConcurrency > 1) {
|
||||
for (let _plugin of filteredSortedInstalledPlugins) {
|
||||
while (running >= globalSearchConcurrency || !isFocused.current) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!isMounted.current || lastSearch.current !== searchText) {
|
||||
break;
|
||||
}
|
||||
running++;
|
||||
searchInPlugin(_plugin)
|
||||
.then(() => {
|
||||
running--;
|
||||
if (lastSearch.current === searchText) {
|
||||
setProgress(
|
||||
prevState => prevState + 1 / filteredInstalledPlugins.length,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
running--;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let _plugin of filteredSortedInstalledPlugins) {
|
||||
if (!isMounted.current || lastSearch.current !== searchText) {
|
||||
break;
|
||||
}
|
||||
while (!isFocused.current) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
await searchInPlugin(_plugin);
|
||||
if (lastSearch.current === searchText) {
|
||||
setProgress(
|
||||
prevState => prevState + 1 / filteredInstalledPlugins.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,3 +151,20 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
|
||||
return { searchResults, globalSearch, progress };
|
||||
};
|
||||
|
||||
function novelResultSorter(
|
||||
{ novels: a, plugin: { name: aName } }: GlobalSearchResult,
|
||||
{ novels: b, plugin: { name: bName } }: GlobalSearchResult,
|
||||
) {
|
||||
if (!a.length && !b.length) {
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
if (!a.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import WebView, { WebViewNavigation } from 'react-native-webview';
|
||||
import { ProgressBar } from 'react-native-paper';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { useBackHandler } from '@hooks';
|
||||
@@ -25,6 +26,7 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => {
|
||||
const { name, url, pluginId, isNovel } = route.params;
|
||||
const isSave = getPlugin(pluginId)?.webStorageUtilized;
|
||||
const uri = resolveUrl(pluginId, url, isNovel);
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
|
||||
const theme = useTheme();
|
||||
const webViewRef = useRef<WebView | null>(null);
|
||||
@@ -107,6 +109,7 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => {
|
||||
onMessage={({ nativeEvent }) =>
|
||||
setTempData(JSON.parse(nativeEvent.data))
|
||||
}
|
||||
containerStyle={{ paddingBottom: bottom }}
|
||||
/>
|
||||
{menuVisible ? (
|
||||
<Menu
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { TabView, TabBar } from 'react-native-tab-view';
|
||||
|
||||
import { useSearch } from '@hooks';
|
||||
@@ -38,6 +38,18 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
if (navigation.isFocused()) {
|
||||
e.preventDefault();
|
||||
|
||||
navigation.navigate('GlobalSearchScreen', {});
|
||||
}
|
||||
}),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const [index, setIndex] = React.useState(0);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
RefreshControl,
|
||||
ListRenderItem,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useBrowseSettings, usePlugins } from '@hooks/persisted';
|
||||
import { PluginItem } from '@plugins/types';
|
||||
import {
|
||||
@@ -20,8 +19,8 @@ import { coverPlaceholderColor } from '@theme/colors';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { getString } from '@strings/translations';
|
||||
import { BrowseScreenProps } from '@navigators/types';
|
||||
import { Button, IconButtonV2 } from '@components';
|
||||
import { BrowseScreenProps, MoreStackScreenProps } from '@navigators/types';
|
||||
import { Button, EmptyView, IconButtonV2 } from '@components';
|
||||
import TrackerCard from '../discover/TrackerCard';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import Animated, {
|
||||
@@ -29,7 +28,10 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { Portal } from 'react-native-paper';
|
||||
import SourceSettingsModal from './Modals/SourceSettings';
|
||||
import { useBoolean } from '@hooks';
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
|
||||
interface AvailableTabProps {
|
||||
searchText: string;
|
||||
@@ -52,6 +54,11 @@ export const InstalledTab = memo(
|
||||
updatePlugin,
|
||||
} = usePlugins();
|
||||
const { showMyAnimeList, showAniList } = useBrowseSettings();
|
||||
const settingsModal = useBoolean();
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string>('');
|
||||
|
||||
const pluginSettings = getPlugin(selectedPluginId)?.pluginSettings;
|
||||
|
||||
const navigateToSource = useCallback(
|
||||
(plugin: PluginItem, showLatestNovels?: boolean) => {
|
||||
navigation.navigate('SourceScreen', {
|
||||
@@ -66,12 +73,18 @@ export const InstalledTab = memo(
|
||||
);
|
||||
|
||||
const searchedPlugins = useMemo(() => {
|
||||
const sortedInstalledPlugins = filteredInstalledPlugins.sort(
|
||||
(plgFirst, plgSecond) => plgFirst.name.localeCompare(plgSecond.name),
|
||||
);
|
||||
if (searchText) {
|
||||
return filteredInstalledPlugins.filter(plg =>
|
||||
plg.name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()),
|
||||
const lowerCaseSearchText = searchText.toLocaleLowerCase();
|
||||
return sortedInstalledPlugins.filter(
|
||||
plg =>
|
||||
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
|
||||
plg.id.includes(lowerCaseSearchText),
|
||||
);
|
||||
} else {
|
||||
return filteredInstalledPlugins;
|
||||
return sortedInstalledPlugins;
|
||||
}
|
||||
}, [searchText, filteredInstalledPlugins]);
|
||||
|
||||
@@ -79,6 +92,8 @@ export const InstalledTab = memo(
|
||||
({ item }) => {
|
||||
return (
|
||||
<Swipeable
|
||||
dragOffsetFromLeftEdge={30}
|
||||
dragOffsetFromRightEdge={30}
|
||||
renderLeftActions={(progress, dragX, ref) => {
|
||||
return (
|
||||
<View
|
||||
@@ -153,7 +168,19 @@ export const InstalledTab = memo(
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
{item.hasUpdate ? (
|
||||
{item.hasSettings ? (
|
||||
<IconButtonV2
|
||||
name="cog-outline"
|
||||
size={22}
|
||||
color={theme.primary}
|
||||
onPress={() => {
|
||||
setSelectedPluginId(item.id);
|
||||
settingsModal.setTrue();
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
) : null}
|
||||
{item.hasUpdate || __DEV__ ? (
|
||||
<IconButtonV2
|
||||
name="download-outline"
|
||||
size={22}
|
||||
@@ -236,6 +263,17 @@ export const InstalledTab = memo(
|
||||
>
|
||||
{getString('browseScreen.installedPlugins')}
|
||||
</Text>
|
||||
|
||||
<Portal>
|
||||
<SourceSettingsModal
|
||||
visible={settingsModal.value}
|
||||
onDismiss={settingsModal.setFalse}
|
||||
title={getString('browseScreen.settings.title')}
|
||||
description={getString('browseScreen.settings.description')}
|
||||
pluginId={selectedPluginId}
|
||||
pluginSettings={pluginSettings}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -244,7 +282,7 @@ export const InstalledTab = memo(
|
||||
);
|
||||
|
||||
interface AvailablePluginCardProps {
|
||||
plugin: PluginItem;
|
||||
plugin: PluginItem & { header: boolean };
|
||||
theme: ThemeColors;
|
||||
installPlugin: (plugin: PluginItem) => Promise<void>;
|
||||
}
|
||||
@@ -266,112 +304,131 @@ const AvailablePluginCard = ({
|
||||
lineHeight: ratio.value * 20,
|
||||
}));
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { backgroundColor: theme.surface }, viewStyles]}
|
||||
>
|
||||
<Animated.View style={{ flexDirection: 'row' }}>
|
||||
<Animated.Image
|
||||
source={{ uri: plugin.iconUrl }}
|
||||
style={[styles.icon, imageStyles]}
|
||||
/>
|
||||
<Animated.View style={styles.details}>
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
<View>
|
||||
{plugin.header ? (
|
||||
<Text style={[styles.listHeader, { color: theme.onSurfaceVariant }]}>
|
||||
{plugin.lang}
|
||||
</Text>
|
||||
) : null}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.surface },
|
||||
viewStyles,
|
||||
]}
|
||||
>
|
||||
<Animated.View style={{ flexDirection: 'row' }}>
|
||||
<Animated.Image
|
||||
source={{ uri: plugin.iconUrl }}
|
||||
style={[
|
||||
{
|
||||
color: theme.onSurface,
|
||||
},
|
||||
styles.name,
|
||||
textStyles,
|
||||
styles.icon,
|
||||
imageStyles,
|
||||
{ backgroundColor: theme.surface },
|
||||
]}
|
||||
>
|
||||
{plugin.name}
|
||||
</Animated.Text>
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
{ color: theme.onSurfaceVariant },
|
||||
styles.addition,
|
||||
textStyles,
|
||||
]}
|
||||
>
|
||||
{`${plugin.lang} - ${plugin.version}`}
|
||||
</Animated.Text>
|
||||
/>
|
||||
<Animated.View style={styles.details}>
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
{
|
||||
color: theme.onSurface,
|
||||
},
|
||||
styles.name,
|
||||
textStyles,
|
||||
]}
|
||||
>
|
||||
{plugin.name}
|
||||
</Animated.Text>
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
{ color: theme.onSurfaceVariant },
|
||||
styles.addition,
|
||||
textStyles,
|
||||
]}
|
||||
>
|
||||
{`${plugin.lang} - ${plugin.version}`}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<IconButtonV2
|
||||
name="download-outline"
|
||||
color={theme.primary}
|
||||
onPress={() => {
|
||||
ratio.value = withTiming(0, { duration: 500 });
|
||||
installPlugin(plugin)
|
||||
.then(() => {
|
||||
showToast(
|
||||
getString('browseScreen.installedPlugin', {
|
||||
name: plugin.name,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
showToast(error.message);
|
||||
ratio.value = 1;
|
||||
});
|
||||
}}
|
||||
size={22}
|
||||
theme={theme}
|
||||
/>
|
||||
</Animated.View>
|
||||
<IconButtonV2
|
||||
name="download-outline"
|
||||
color={theme.primary}
|
||||
onPress={() => {
|
||||
ratio.value = withTiming(0, { duration: 500 });
|
||||
installPlugin(plugin)
|
||||
.then(() => {
|
||||
showToast(
|
||||
getString('browseScreen.installedPlugin', {
|
||||
name: plugin.name,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
showToast(error.message);
|
||||
ratio.value = 1;
|
||||
});
|
||||
}}
|
||||
size={22}
|
||||
theme={theme}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailableTab = memo(({ searchText, theme }: AvailableTabProps) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const {
|
||||
filteredAvailablePlugins,
|
||||
languagesFilter,
|
||||
refreshPlugins,
|
||||
installPlugin,
|
||||
} = usePlugins();
|
||||
const navigation = useNavigation<MoreStackScreenProps['navigation']>();
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const list = [];
|
||||
const group = groupBy(
|
||||
searchText
|
||||
? filteredAvailablePlugins.filter(plg =>
|
||||
plg.name.toLocaleLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: filteredAvailablePlugins,
|
||||
'lang',
|
||||
);
|
||||
for (const language of languagesFilter) {
|
||||
if (group[language]?.length) {
|
||||
list.push({
|
||||
header: language,
|
||||
data: group[language],
|
||||
});
|
||||
}
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { filteredAvailablePlugins, refreshPlugins, installPlugin } =
|
||||
usePlugins();
|
||||
|
||||
const searchedPlugins = useMemo(() => {
|
||||
let res = filteredAvailablePlugins;
|
||||
if (searchText) {
|
||||
const lowerCaseSearchText = searchText.toLocaleLowerCase();
|
||||
res = filteredAvailablePlugins.filter(
|
||||
plg =>
|
||||
plg.name.toLocaleLowerCase().includes(lowerCaseSearchText) ||
|
||||
plg.id.includes(lowerCaseSearchText),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
let previousLang: string | null = null;
|
||||
return res
|
||||
.sort((a, b) => a.lang.localeCompare(b.lang))
|
||||
.map(plg => {
|
||||
if (plg.lang !== previousLang) {
|
||||
previousLang = plg.lang;
|
||||
return { ...plg, header: true };
|
||||
} else {
|
||||
return { ...plg, header: false };
|
||||
}
|
||||
});
|
||||
}, [searchText, filteredAvailablePlugins]);
|
||||
|
||||
const renderItem: ListRenderItem<PluginItem> = useCallback(
|
||||
({ item }) => {
|
||||
return (
|
||||
<AvailablePluginCard
|
||||
plugin={item}
|
||||
theme={theme}
|
||||
installPlugin={installPlugin}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[theme, sections],
|
||||
);
|
||||
const renderItem: FlashListRenderItem<PluginItem & { header: boolean }> =
|
||||
useCallback(
|
||||
({ item }) => {
|
||||
return (
|
||||
<AvailablePluginCard
|
||||
plugin={item}
|
||||
theme={theme}
|
||||
installPlugin={installPlugin}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[theme, searchedPlugins],
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<FlashList
|
||||
estimatedItemSize={64}
|
||||
data={searchedPlugins}
|
||||
extraData={theme}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyExtractor={item => item.id + '_available'}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -388,13 +445,38 @@ export const AvailableTab = memo(({ searchText, theme }: AvailableTabProps) => {
|
||||
progressBackgroundColor={theme.primary}
|
||||
/>
|
||||
}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={({ section: { header, data } }) =>
|
||||
data.length ? (
|
||||
<Text style={[styles.listHeader, { color: theme.onSurfaceVariant }]}>
|
||||
{header}
|
||||
</Text>
|
||||
) : null
|
||||
ListEmptyComponent={
|
||||
!filteredAvailablePlugins.length ? (
|
||||
<View style={{ marginTop: 100 }}>
|
||||
<EmptyView
|
||||
icon="(・Д・。"
|
||||
description=" No repositories yet. Add your first plugin repository to get
|
||||
started."
|
||||
actions={[
|
||||
{
|
||||
iconName: 'cog-outline',
|
||||
title: 'Add Repository',
|
||||
onPress: () =>
|
||||
navigation.navigate('MoreStack', {
|
||||
screen: 'SettingsStack',
|
||||
params: {
|
||||
screen: 'RespositorySettings',
|
||||
},
|
||||
}),
|
||||
},
|
||||
]}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 100 }}>
|
||||
<EmptyView
|
||||
icon="(・Д・。"
|
||||
description="No plugins available for this search term"
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -436,8 +518,4 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
spinner: {
|
||||
marginRight: 12,
|
||||
marginLeft: -1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Modal, overlay, TextInput } from 'react-native-paper';
|
||||
import { Button } from '@components/index';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Storage } from '@plugins/helpers/storage';
|
||||
|
||||
interface PluginSetting {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PluginSettings {
|
||||
[key: string]: PluginSetting;
|
||||
}
|
||||
|
||||
interface SourceSettingsModal {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
pluginId: string;
|
||||
pluginSettings?: PluginSettings;
|
||||
}
|
||||
|
||||
const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
|
||||
onDismiss,
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
pluginId,
|
||||
pluginSettings,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [formValues, setFormValues] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginSettings) {
|
||||
const storage = new Storage(pluginId);
|
||||
|
||||
const loadFormValues = async () => {
|
||||
const loadedValues = await Promise.all(
|
||||
Object.keys(pluginSettings).map(async key => {
|
||||
const storedValue = await storage.get(key);
|
||||
return {
|
||||
key,
|
||||
value:
|
||||
storedValue !== null ? storedValue : pluginSettings[key].value,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const initialFormValues = Object.fromEntries(
|
||||
loadedValues.map(({ key, value }) => [key, value]),
|
||||
);
|
||||
|
||||
setFormValues(initialFormValues);
|
||||
};
|
||||
|
||||
loadFormValues();
|
||||
}
|
||||
}, [pluginSettings, pluginId]);
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const storage = new Storage(pluginId);
|
||||
Object.entries(formValues).forEach(([key, value]) => {
|
||||
storage.set(key, value);
|
||||
});
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
if (!pluginSettings || Object.keys(pluginSettings).length === 0) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
contentContainerStyle={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[{ color: theme.onSurfaceVariant }]}>
|
||||
{description || 'No settings available.'}
|
||||
</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
contentContainerStyle={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[{ color: theme.onSurfaceVariant }]}>{description}</Text>
|
||||
|
||||
{Object.entries(pluginSettings).map(([key, setting]) => (
|
||||
<TextInput
|
||||
key={key}
|
||||
mode="outlined"
|
||||
label={setting.label}
|
||||
value={formValues[key] || ''}
|
||||
onChangeText={value => handleChange(key, value)}
|
||||
placeholder={`Enter ${setting.label}`}
|
||||
placeholderTextColor={theme.onSurfaceDisabled}
|
||||
underlineColor={theme.outline}
|
||||
style={[{ color: theme.onSurface }, styles.textInput]}
|
||||
theme={{ colors: { ...theme } }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<View style={styles.customCSSButtons}>
|
||||
<Button
|
||||
onPress={handleSave}
|
||||
style={styles.button}
|
||||
title={getString('common.save')}
|
||||
mode="contained"
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourceSettingsModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
margin: 30,
|
||||
padding: 24,
|
||||
borderRadius: 28,
|
||||
},
|
||||
textInput: {
|
||||
height: 50,
|
||||
borderRadius: 14,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
customCSSButtons: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
button: {
|
||||
marginTop: 16,
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import LoadingNovel from './LoadingNovel';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
const GlobalSearchSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
const styles = createStyleSheet();
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const items: Array<number> = [1, 2, 3, 4];
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, StyleSheet } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
backgroundColor: string;
|
||||
@@ -19,6 +20,7 @@ const LoadingNovel: React.FC<Props> = ({
|
||||
pictureWidth,
|
||||
displayMode,
|
||||
}) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
let randomNumber = Math.random();
|
||||
randomNumber < 0.1 ? (randomNumber = 0) : null;
|
||||
@@ -35,6 +37,7 @@ const LoadingNovel: React.FC<Props> = ({
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={pictureHeight}
|
||||
width={pictureWidth}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
{displayMode === 2 || displayMode === 0 ? null : (
|
||||
<>
|
||||
@@ -43,12 +46,14 @@ const LoadingNovel: React.FC<Props> = ({
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={pictureWidth}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={randomNumber * pictureWidth}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -64,6 +69,7 @@ const LoadingNovel: React.FC<Props> = ({
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={40}
|
||||
width={40}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
|
||||
<ShimmerPlaceHolder
|
||||
@@ -71,12 +77,14 @@ const LoadingNovel: React.FC<Props> = ({
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={18}
|
||||
width={textWidth}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.picture}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={20}
|
||||
width={chapterNumberWidth}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -4,17 +4,19 @@ import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
const styles = createStyleSheet(theme);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const renderLoadingRect = (item: number, index: number) => {
|
||||
let randomNumber = Math.random();
|
||||
@@ -25,6 +27,7 @@ const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={120 + Math.random() * 28}
|
||||
width={100}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<View style={styles.loadingText}>
|
||||
<ShimmerPlaceHolder
|
||||
@@ -32,18 +35,21 @@ const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={Dimensions.get('window').width - 140}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={Dimensions.get('window').width - 140}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={randomNumber * (Dimensions.get('window').width - 140)}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { View, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import LoadingNovel from '@screens/browse/loadingAnimation/LoadingNovel';
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
@@ -16,7 +16,7 @@ const SourceScreenSkeletonLoading: React.FC<Props> = ({
|
||||
theme,
|
||||
completeRow,
|
||||
}) => {
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
|
||||
useLibrarySettings();
|
||||
|
||||
@@ -4,17 +4,19 @@ import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
const styles = createStyleSheet(theme);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const renderLoadingRect = (item: number, index: number) => {
|
||||
let randomNumber = Math.random();
|
||||
@@ -25,6 +27,7 @@ const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={120 + Math.random() * 28}
|
||||
width={100}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<View style={styles.loadingText}>
|
||||
<ShimmerPlaceHolder
|
||||
@@ -32,18 +35,21 @@ const MalLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={Dimensions.get('window').width - 140}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={Dimensions.get('window').width - 140}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={randomNumber * (Dimensions.get('window').width - 140)}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
+26
-7
@@ -2,18 +2,30 @@ import { FlatList, StyleSheet } from 'react-native';
|
||||
import React from 'react';
|
||||
import { Appbar, List, SwitchItem } from '@components';
|
||||
|
||||
import { useBrowseSettings, usePlugins, useTheme } from '@hooks/persisted';
|
||||
import {
|
||||
useBrowseSettings,
|
||||
usePlugins,
|
||||
useTheme,
|
||||
} from '@hooks/persisted/index';
|
||||
import { getString } from '@strings/translations';
|
||||
import { languages } from '@utils/constants/languages';
|
||||
import { BrowseSettingsScreenProp } from '@navigators/types';
|
||||
import { BrowseSettingsScreenProp } from '@navigators/types/index';
|
||||
import { useBoolean } from '@hooks';
|
||||
import ConcurrentSearchesModal from '@screens/browse/settings/modals/ConcurrentSearchesModal';
|
||||
|
||||
const BrowseSettings = ({ navigation }: BrowseSettingsScreenProp) => {
|
||||
const theme = useTheme();
|
||||
const { goBack } = navigation;
|
||||
|
||||
const { languagesFilter, toggleLanguageFilter } = usePlugins();
|
||||
const { showMyAnimeList, showAniList, setBrowseSettings } =
|
||||
useBrowseSettings();
|
||||
const {
|
||||
showMyAnimeList,
|
||||
showAniList,
|
||||
globalSearchConcurrency,
|
||||
setBrowseSettings,
|
||||
} = useBrowseSettings();
|
||||
|
||||
const globalSearchConcurrencyModal = useBoolean();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -22,6 +34,12 @@ const BrowseSettings = ({ navigation }: BrowseSettingsScreenProp) => {
|
||||
handleGoBack={goBack}
|
||||
theme={theme}
|
||||
/>
|
||||
<ConcurrentSearchesModal
|
||||
globalSearchConcurrency={globalSearchConcurrency ?? 1}
|
||||
modalVisible={globalSearchConcurrencyModal.value}
|
||||
hideModal={globalSearchConcurrencyModal.setFalse}
|
||||
theme={theme}
|
||||
/>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.container}
|
||||
ListHeaderComponent={
|
||||
@@ -29,9 +47,10 @@ const BrowseSettings = ({ navigation }: BrowseSettingsScreenProp) => {
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('browseScreen.globalSearch')}
|
||||
</List.SubHeader>
|
||||
<List.InfoItem
|
||||
title={getString('browseSettingsScreen.searchAllWarning')}
|
||||
icon="information-outline"
|
||||
<List.Item
|
||||
title={getString('browseSettingsScreen.concurrentSearches')}
|
||||
description={(globalSearchConcurrency ?? 1).toString()}
|
||||
onPress={globalSearchConcurrencyModal.setTrue}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Divider theme={theme} />
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Text, StyleSheet } from 'react-native';
|
||||
|
||||
import { Portal, Modal, overlay } from 'react-native-paper';
|
||||
|
||||
import { RadioButton } from '@components/RadioButton/RadioButton';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useBrowseSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface DisplayModeModalProps {
|
||||
globalSearchConcurrency: number;
|
||||
modalVisible: boolean;
|
||||
hideModal: () => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const ConcurrentSearchesModal: React.FC<DisplayModeModalProps> = ({
|
||||
theme,
|
||||
globalSearchConcurrency,
|
||||
hideModal,
|
||||
modalVisible,
|
||||
}) => {
|
||||
const { setBrowseSettings } = useBrowseSettings();
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
onDismiss={hideModal}
|
||||
contentContainerStyle={[
|
||||
styles.containerStyle,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalHeader, { color: theme.onSurface }]}>
|
||||
{getString('browseSettingsScreen.concurrentSearches')}
|
||||
</Text>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(concurrency => (
|
||||
<RadioButton
|
||||
key={concurrency}
|
||||
status={globalSearchConcurrency === concurrency}
|
||||
onPress={() =>
|
||||
setBrowseSettings({ globalSearchConcurrency: concurrency })
|
||||
}
|
||||
label={concurrency.toString()}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConcurrentSearchesModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
containerStyle: {
|
||||
paddingVertical: 20,
|
||||
margin: 20,
|
||||
borderRadius: 28,
|
||||
},
|
||||
modalHeader: {
|
||||
paddingHorizontal: 24,
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, SectionList, Text } from 'react-native';
|
||||
import dayjs from 'dayjs';
|
||||
import { Portal } from 'react-native-paper';
|
||||
@@ -71,6 +71,23 @@ const HistoryScreen = ({ navigation }: HistoryScreenProps) => {
|
||||
setFalse: closeClearHistoryDialog,
|
||||
} = useBoolean();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
let lastNovel = history[0];
|
||||
if (navigation.isFocused() && lastNovel) {
|
||||
e.preventDefault();
|
||||
|
||||
navigation.navigate('Novel', {
|
||||
name: lastNovel.novelName,
|
||||
path: lastNovel.novelPath,
|
||||
pluginId: lastNovel.pluginId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
[navigation, history],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchbarV2
|
||||
@@ -105,9 +122,7 @@ const HistoryScreen = ({ navigation }: HistoryScreenProps) => {
|
||||
renderItem={({ item }) => (
|
||||
<HistoryCard
|
||||
history={item}
|
||||
navigation={navigation}
|
||||
handleRemoveFromHistory={removeChapterFromHistory}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'react-native';
|
||||
|
||||
import { IconButtonV2 } from '@components';
|
||||
|
||||
import { History, NovelInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { coverPlaceholderColor } from '@theme/colors';
|
||||
import { getString } from '@strings/translations';
|
||||
import { HistoryScreenProps } from '@navigators/types';
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
import { History, NovelInfo } from '@database/types';
|
||||
import { HistoryScreenProps } from '@navigators/types';
|
||||
|
||||
import { coverPlaceholderColor } from '@theme/colors';
|
||||
|
||||
interface HistoryCardProps {
|
||||
history: History;
|
||||
handleRemoveFromHistory: (chapterId: number) => void;
|
||||
navigation: HistoryScreenProps['navigation'];
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
history,
|
||||
navigation,
|
||||
handleRemoveFromHistory,
|
||||
theme,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { navigate } = useNavigation<HistoryScreenProps['navigation']>();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
onPress={() =>
|
||||
navigation.navigate('Chapter', {
|
||||
navigate('Chapter', {
|
||||
novel: {
|
||||
path: history.novelPath,
|
||||
name: history.novelName,
|
||||
@@ -44,7 +45,7 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
<View style={styles.imageAndNameContainer}>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
navigation.navigate('Novel', {
|
||||
navigate('Novel', {
|
||||
name: history.name,
|
||||
path: history.novelPath,
|
||||
pluginId: history.pluginId,
|
||||
@@ -110,7 +111,6 @@ const styles = StyleSheet.create({
|
||||
novelName: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
chapterName: {},
|
||||
imageAndNameContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -3,76 +3,72 @@ import { StyleSheet, View } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const HistorySkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
|
||||
const renderLoadingChapter = (item: number, index: number) => {
|
||||
return (
|
||||
<View key={'historyLoading' + item}>
|
||||
{index === 0 || Math.random() > 0.6 ? (
|
||||
const renderLoadingChapter = (index: number) => (
|
||||
<View key={`historyLoading${index}`}>
|
||||
{index === 0 || Math.random() > 0.6 ? (
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.date}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={19.3}
|
||||
width={Math.random() * 40 + 50}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.chapterCtn}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.picture}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={80}
|
||||
width={56}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<View style={styles.textCtn}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.date}
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={19.3}
|
||||
width={Math.random() * 40 + 50}
|
||||
height={16}
|
||||
width={208.7}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.chapterCtn} key={index}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.picture}
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={80}
|
||||
width={56}
|
||||
height={12}
|
||||
width={208.7}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<View style={styles.textCtn}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={208.7}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={12}
|
||||
width={208.7}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonCtn}>
|
||||
</View>
|
||||
<View style={styles.buttonCtn}>
|
||||
{Array.from({ length: 2 }).map((_, buttonIndex) => (
|
||||
<ShimmerPlaceHolder
|
||||
key={buttonIndex}
|
||||
style={styles.button}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={24}
|
||||
width={24}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonCtn}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.button}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
</View>
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for (let index = 0; index < Math.random() * 3 + 3; index++) {
|
||||
items.push(index);
|
||||
}
|
||||
const items = Array.from({ length: Math.floor(Math.random() * 3 + 3) });
|
||||
|
||||
return <View>{items.map(renderLoadingChapter)}</View>;
|
||||
return <View>{items.map((_, index) => renderLoadingChapter(index))}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import { BottomSheetModal } from '@gorhom/bottom-sheet';
|
||||
import {
|
||||
@@ -85,6 +85,18 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
|
||||
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
if (navigation.isFocused()) {
|
||||
e.preventDefault();
|
||||
|
||||
bottomSheetRef.current?.present?.();
|
||||
}
|
||||
}),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const renderTabBar = (
|
||||
props: SceneRendererProps & { navigationState: State },
|
||||
) =>
|
||||
@@ -102,7 +114,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
},
|
||||
styles.tabBar,
|
||||
]}
|
||||
tabStyle={{ width: 'auto' }}
|
||||
tabStyle={{ width: 'auto', minWidth: 100 }}
|
||||
gap={8}
|
||||
renderLabel={({ route, color }) => (
|
||||
<Row>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View, Pressable, Text, ScrollView } from 'react-native';
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
import { List } from '@components';
|
||||
|
||||
import { MoreHeader } from './components/MoreHeader';
|
||||
import { useDownload, useLibrarySettings, useTheme } from '@hooks/persisted';
|
||||
import { useLibrarySettings, useTheme } from '@hooks/persisted';
|
||||
import { MoreStackScreenProps } from '@navigators/types';
|
||||
import Switch from '@components/Switch/Switch';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
|
||||
|
||||
const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { downloadQueue } = useDownload();
|
||||
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
|
||||
ServiceManager.manager.STORE_KEY,
|
||||
);
|
||||
const {
|
||||
incognitoMode = false,
|
||||
downloadedOnlyMode = false,
|
||||
@@ -24,6 +28,23 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
const enableIncognitoMode = () =>
|
||||
setLibrarySettings({ incognitoMode: !incognitoMode });
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
if (navigation.isFocused()) {
|
||||
e.preventDefault();
|
||||
|
||||
navigation.navigate('MoreStack', {
|
||||
screen: 'SettingsStack',
|
||||
params: {
|
||||
screen: 'Settings',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<MoreHeader
|
||||
@@ -107,14 +128,16 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
</Pressable>
|
||||
<List.Divider theme={theme} />
|
||||
<List.Item
|
||||
title={getString('moreScreen.downloadQueue')}
|
||||
title={'Task Queue'}
|
||||
description={
|
||||
downloadQueue.length > 0 ? downloadQueue.length + ' remaining' : ''
|
||||
taskQueue && taskQueue.length > 0
|
||||
? taskQueue.length + ' remaining'
|
||||
: ''
|
||||
}
|
||||
icon="progress-download"
|
||||
onPress={() =>
|
||||
navigation.navigate('MoreStack', {
|
||||
screen: 'DownloadQueue',
|
||||
screen: 'TaskQueue',
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
|
||||
@@ -8,36 +8,36 @@ import {
|
||||
overlay,
|
||||
} from 'react-native-paper';
|
||||
|
||||
import { useDownload, useTheme } from '@hooks/persisted';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
import { showToast } from '../../utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar, EmptyView } from '@components';
|
||||
import { DownloadQueueScreenProps } from '@navigators/types';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
import { TaskQueueScreenProps } from '@navigators/types';
|
||||
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
|
||||
const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
const DownloadQueue = ({ navigation }: TaskQueueScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const { downloadQueue, resumeDowndload, pauseDownload, cancelDownload } =
|
||||
useDownload();
|
||||
const [isDownloading, setIsDownloading] = useState(
|
||||
ServiceManager.manager.isRunning,
|
||||
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
|
||||
ServiceManager.manager.STORE_KEY,
|
||||
);
|
||||
const [isRunning, setIsRunning] = useState(ServiceManager.manager.isRunning);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const openMenu = () => setVisible(true);
|
||||
const closeMenu = () => setVisible(false);
|
||||
useEffect(() => {
|
||||
if (downloadQueue.length === 0) {
|
||||
setIsDownloading(false);
|
||||
if (taskQueue?.length === 0) {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [downloadQueue]);
|
||||
}, [taskQueue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Appbar
|
||||
title={getString('moreScreen.downloadQueue')}
|
||||
title={'Task Queue'}
|
||||
handleGoBack={navigation.goBack}
|
||||
theme={theme}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
visible={visible}
|
||||
onDismiss={closeMenu}
|
||||
anchor={
|
||||
downloadQueue.length ? (
|
||||
taskQueue?.length ? (
|
||||
<MaterialAppbar.Action
|
||||
icon="dots-vertical"
|
||||
iconColor={theme.onSurface}
|
||||
@@ -57,8 +57,8 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
cancelDownload();
|
||||
setIsDownloading(false);
|
||||
ServiceManager.manager.stop();
|
||||
setIsRunning(false);
|
||||
showToast(getString('downloadScreen.cancelled'));
|
||||
closeMenu();
|
||||
}}
|
||||
@@ -69,19 +69,15 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
</Appbar>
|
||||
<FlatList
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 100 }}
|
||||
keyExtractor={item => 'download_chapter_' + item.data.chapterId}
|
||||
data={downloadQueue}
|
||||
renderItem={({ item }) => (
|
||||
keyExtractor={(item, index) => 'task_' + index}
|
||||
data={taskQueue || []}
|
||||
renderItem={({ item, index }) => (
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ color: theme.onSurface }}>
|
||||
{item.data.chapterName}
|
||||
{item.name} - {ServiceManager.manager.getTaskDescription(item)}
|
||||
</Text>
|
||||
<ProgressBar
|
||||
indeterminate={
|
||||
isDownloading &&
|
||||
downloadQueue.length > 0 &&
|
||||
downloadQueue[0].data.chapterId === item.data.chapterId
|
||||
}
|
||||
indeterminate={taskQueue && taskQueue.length > 0 && index === 0}
|
||||
color={theme.primary}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
@@ -90,29 +86,27 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
ListEmptyComponent={
|
||||
<EmptyView
|
||||
icon="(・o・;)"
|
||||
description={getString('downloadScreen.noDownloads')}
|
||||
description={'No running tasks'}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{downloadQueue.length > 0 ? (
|
||||
{taskQueue && taskQueue.length > 0 ? (
|
||||
<FAB
|
||||
style={[styles.fab, { backgroundColor: theme.primary, bottom }]}
|
||||
color={theme.onPrimary}
|
||||
label={
|
||||
isDownloading
|
||||
? getString('common.pause')
|
||||
: getString('common.resume')
|
||||
isRunning ? getString('common.pause') : getString('common.resume')
|
||||
}
|
||||
uppercase={false}
|
||||
icon={isDownloading ? 'pause' : 'play'}
|
||||
icon={isRunning ? 'pause' : 'play'}
|
||||
onPress={() => {
|
||||
if (isDownloading) {
|
||||
pauseDownload();
|
||||
setIsDownloading(false);
|
||||
if (isRunning) {
|
||||
ServiceManager.manager.pause();
|
||||
setIsRunning(false);
|
||||
} else {
|
||||
resumeDowndload();
|
||||
setIsDownloading(true);
|
||||
ServiceManager.manager.resume();
|
||||
setIsRunning(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -50,6 +50,7 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import NovelAppbar from './components/NovelAppbar';
|
||||
import { resolveUrl } from '@services/plugin/fetch';
|
||||
import { updateChapterProgressByIds } from '@database/queries/ChapterQueries';
|
||||
|
||||
const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
const { name, path, pluginId } = route.params;
|
||||
@@ -242,11 +243,15 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
}
|
||||
|
||||
if (selected.some(obj => !obj.unread)) {
|
||||
const chapterIds = selected.map(chapter => chapter.id);
|
||||
|
||||
list.push({
|
||||
icon: 'check-outline',
|
||||
onPress: () => {
|
||||
markChaptersUnread(selected);
|
||||
updateChapterProgressByIds(chapterIds, 0);
|
||||
setSelected([]);
|
||||
refreshChapters();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -375,6 +380,8 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
<Portal>
|
||||
{selected.length === 0 ? (
|
||||
<NovelAppbar
|
||||
novel={novel}
|
||||
chapters={chapters}
|
||||
deleteChapters={deleteChs}
|
||||
downloadChapters={downloadChs}
|
||||
showEditInfoModal={showEditInfoModal}
|
||||
|
||||
@@ -107,7 +107,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
? theme.outline
|
||||
: bookmark
|
||||
? theme.primary
|
||||
: theme.onSurfaceVariant,
|
||||
: theme.onSurface,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Modal, TextInput, Text } from 'react-native-paper';
|
||||
import { openDocumentTree } from 'react-native-saf-x';
|
||||
|
||||
import { Button, List, SwitchItem } from '@components';
|
||||
|
||||
import { useBoolean } from '@hooks';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
interface ChooseEpubLocationModalProps {
|
||||
isVisible: boolean;
|
||||
onSubmit?: (uri: string) => void;
|
||||
hideModal: () => void;
|
||||
}
|
||||
|
||||
const ChooseEpubLocationModal: React.FC<ChooseEpubLocationModalProps> = ({
|
||||
isVisible,
|
||||
onSubmit: onSubmitProp,
|
||||
hideModal,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
epubLocation = '',
|
||||
epubUseAppTheme = false,
|
||||
epubUseCustomCSS = false,
|
||||
epubUseCustomJS = false,
|
||||
setChapterReaderSettings,
|
||||
} = useChapterReaderSettings();
|
||||
|
||||
const [uri, setUri] = useState(epubLocation);
|
||||
const useAppTheme = useBoolean(epubUseAppTheme);
|
||||
const useCustomCSS = useBoolean(epubUseCustomCSS);
|
||||
const useCustomJS = useBoolean(epubUseCustomJS);
|
||||
|
||||
const onDismiss = () => {
|
||||
hideModal();
|
||||
setUri(epubLocation);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
setChapterReaderSettings({
|
||||
epubLocation: uri,
|
||||
epubUseAppTheme: useAppTheme.value,
|
||||
epubUseCustomCSS: useCustomCSS.value,
|
||||
epubUseCustomJS: useCustomJS.value,
|
||||
});
|
||||
|
||||
onSubmitProp?.(uri);
|
||||
hideModal();
|
||||
};
|
||||
|
||||
const openFolderPicker = async () => {
|
||||
try {
|
||||
const resultUri = await openDocumentTree(true);
|
||||
if (resultUri) {
|
||||
setUri(resultUri.uri);
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
onDismiss={onDismiss}
|
||||
contentContainerStyle={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: theme.overlay3 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHeaderCtn}>
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
{getString('novelScreen.convertToEpubModal.chooseLocation')}
|
||||
</Text>
|
||||
<TextInput
|
||||
onChangeText={setUri}
|
||||
value={uri}
|
||||
placeholder={getString('novelScreen.convertToEpubModal.pathToFolder')}
|
||||
onSubmitEditing={onSubmit}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
underlineColor={theme.outline}
|
||||
dense
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon="folder-edit-outline"
|
||||
onPress={openFolderPicker}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settings}>
|
||||
<SwitchItem
|
||||
label={getString('novelScreen.convertToEpubModal.useReaderTheme')}
|
||||
value={useAppTheme.value}
|
||||
onPress={useAppTheme.toggle}
|
||||
theme={theme}
|
||||
/>
|
||||
<SwitchItem
|
||||
label={getString('novelScreen.convertToEpubModal.useCustomCSS')}
|
||||
value={useCustomCSS.value}
|
||||
onPress={useCustomCSS.toggle}
|
||||
theme={theme}
|
||||
/>
|
||||
<SwitchItem
|
||||
label={getString('novelScreen.convertToEpubModal.useCustomJS')}
|
||||
description={getString(
|
||||
'novelScreen.convertToEpubModal.useCustomJSWarning',
|
||||
)}
|
||||
value={useCustomJS.value}
|
||||
onPress={useCustomJS.toggle}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
<List.InfoItem
|
||||
title={getString('novelScreen.convertToEpubModal.chaptersWarning')}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.modalFooterCtn}>
|
||||
<Button title={getString('common.submit')} onPress={onSubmit} />
|
||||
<Button title={getString('common.cancel')} onPress={hideModal} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChooseEpubLocationModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
margin: 30,
|
||||
borderRadius: 32,
|
||||
},
|
||||
settings: {
|
||||
marginTop: 12,
|
||||
padding: 24,
|
||||
},
|
||||
modalHeaderCtn: {
|
||||
padding: 20,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
modalFooterCtn: {
|
||||
flexDirection: 'row-reverse',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
@@ -34,19 +34,22 @@ const EditInfoModal = ({
|
||||
novel,
|
||||
setNovel,
|
||||
}: EditInfoModalProps) => {
|
||||
const [tag, setTag] = useState('');
|
||||
const initialNovelInfo = { ...novel };
|
||||
const [novelInfo, setNovelInfo] = useState(novel);
|
||||
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
|
||||
const removeTag = (t: string) => {
|
||||
let tags = novel.genres?.split(',').filter(item => item !== t);
|
||||
setNovel({ ...novel, genres: tags?.join(',') });
|
||||
setNovelInfo({
|
||||
...novel,
|
||||
genres: novelInfo.genres
|
||||
?.split(',')
|
||||
.filter(item => item !== t)
|
||||
?.join(','),
|
||||
});
|
||||
};
|
||||
|
||||
const status = [
|
||||
NovelStatus.Ongoing,
|
||||
NovelStatus.OnHiatus,
|
||||
NovelStatus.Completed,
|
||||
NovelStatus.Unknown,
|
||||
NovelStatus.Cancelled,
|
||||
];
|
||||
const status = Object.values(NovelStatus);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -84,19 +87,21 @@ const EditInfoModal = ({
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor:
|
||||
novel.status === item ? theme.rippleColor : 'transparent',
|
||||
novelInfo.status === item
|
||||
? theme.rippleColor
|
||||
: 'transparent',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
android_ripple={{
|
||||
color: theme.rippleColor,
|
||||
}}
|
||||
onPress={() => setNovel({ ...novel, status: item })}
|
||||
onPress={() => setNovelInfo({ ...novel, status: item })}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color:
|
||||
novel.status === item
|
||||
novelInfo.status === item
|
||||
? theme.primary
|
||||
: theme.onSurfaceVariant,
|
||||
}}
|
||||
@@ -109,68 +114,82 @@ const EditInfoModal = ({
|
||||
</ScrollView>
|
||||
</View>
|
||||
<TextInput
|
||||
defaultValue={initialNovelInfo.name}
|
||||
value={novelInfo.name}
|
||||
placeholder={getString('novelScreen.edit.title', {
|
||||
title: novel.name,
|
||||
})}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, name: text })}
|
||||
onChangeText={text => setNovelInfo({ ...novel, name: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
defaultValue={initialNovelInfo.author}
|
||||
value={novelInfo.author}
|
||||
placeholder={getString('novelScreen.edit.author', {
|
||||
author: novel.author,
|
||||
})}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, author: text })}
|
||||
onChangeText={text => setNovelInfo({ ...novel, author: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
defaultValue={initialNovelInfo.artist}
|
||||
value={novelInfo.artist}
|
||||
placeholder={'Artist: ' + novel.artist}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, artist: text })}
|
||||
onChangeText={text => setNovelInfo({ ...novel, artist: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
defaultValue={initialNovelInfo.summary}
|
||||
value={novelInfo.summary}
|
||||
placeholder={getString('novelScreen.edit.summary', {
|
||||
summary: novel.summary?.substring(0, 16),
|
||||
})}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
onChangeText={text => setNovel({ ...novel, summary: text })}
|
||||
onChangeText={text => setNovelInfo({ ...novel, summary: text })}
|
||||
theme={{ colors: { ...theme } }}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
value={newGenre}
|
||||
placeholder={getString('novelScreen.edit.addTag')}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
onChangeText={text => setTag(text)}
|
||||
onChangeText={text => setNewGenre(text)}
|
||||
onSubmitEditing={() => {
|
||||
setNovel({ ...novel, genres: novel.genres + ',' + tag });
|
||||
setTag('');
|
||||
setNovelInfo(prevVal => ({
|
||||
...prevVal,
|
||||
genres: novelInfo.genres
|
||||
? `${novelInfo.genres},`
|
||||
: '' + newGenre.trim(),
|
||||
}));
|
||||
setNewGenre('');
|
||||
}}
|
||||
theme={{ colors: { ...theme } }}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
|
||||
{novel.genres !== undefined && novel.genres !== '' ? (
|
||||
{novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
|
||||
<FlatList
|
||||
contentContainerStyle={{ marginVertical: 8 }}
|
||||
style={{ marginVertical: 8 }}
|
||||
horizontal
|
||||
data={novel.genres?.split(',')}
|
||||
keyExtractor={(item, index) => 'novelTag' + index}
|
||||
data={novelInfo.genres?.split(',')}
|
||||
keyExtractor={(_, index) => 'novelTag' + index}
|
||||
renderItem={({ item }) => (
|
||||
<GenreChip theme={theme} onPress={() => removeTag(item)}>
|
||||
{item}
|
||||
@@ -179,10 +198,20 @@ const EditInfoModal = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
) : null}
|
||||
<View style={{ flexDirection: 'row-reverse' }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
updateNovelInfo(novel);
|
||||
setNovelInfo(initialNovelInfo);
|
||||
updateNovelInfo(initialNovelInfo);
|
||||
}}
|
||||
>
|
||||
{getString('common.reset')}
|
||||
</Button>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Button
|
||||
onPress={() => {
|
||||
setNovel(novelInfo);
|
||||
updateNovelInfo(novelInfo);
|
||||
hideModal();
|
||||
}}
|
||||
>
|
||||
@@ -223,7 +252,7 @@ const GenreChip = ({
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.onSurfaceVariant,
|
||||
color: theme.onSecondaryContainer,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
@@ -231,7 +260,7 @@ const GenreChip = ({
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
color={theme.primary}
|
||||
color={theme.onSecondaryContainer}
|
||||
size={18}
|
||||
onPress={onPress}
|
||||
style={{ marginLeft: 4 }}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { IconButton, Portal } from 'react-native-paper';
|
||||
import ChooseEpubLocationModal from './ChooseEpubLocationModal';
|
||||
import { StatusBar } from 'react-native';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
|
||||
import EpubBuilder from '@cd-z/react-native-epub-creator';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
|
||||
import { useChapterReaderSettings } from '@hooks/persisted';
|
||||
import { useBoolean } from '@hooks/index';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import FileManager from '@native/FileManager';
|
||||
|
||||
interface EpubIconButtonProps {
|
||||
theme: ThemeColors;
|
||||
novel: NovelInfo;
|
||||
chapters: ChapterInfo[];
|
||||
}
|
||||
|
||||
const EpubIconButton: React.FC<EpubIconButtonProps> = ({
|
||||
theme,
|
||||
novel,
|
||||
chapters,
|
||||
}) => {
|
||||
const {
|
||||
value: isVisible,
|
||||
setTrue: showModal,
|
||||
setFalse: hideModal,
|
||||
} = useBoolean(false);
|
||||
const readerSettings = useChapterReaderSettings();
|
||||
const {
|
||||
epubUseAppTheme = false,
|
||||
epubUseCustomCSS = false,
|
||||
epubUseCustomJS = false,
|
||||
} = useChapterReaderSettings();
|
||||
|
||||
const epubStyle = useMemo(
|
||||
() =>
|
||||
`${
|
||||
epubUseAppTheme
|
||||
? `
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
padding-top: ${StatusBar.currentHeight};
|
||||
word-wrap: break-word;
|
||||
}
|
||||
body {
|
||||
padding-left: ${readerSettings.padding}%;
|
||||
padding-right: ${readerSettings.padding}%;
|
||||
padding-bottom: 40px;
|
||||
font-size: ${readerSettings.textSize}px;
|
||||
color: ${readerSettings.textColor};
|
||||
text-align: ${readerSettings.textAlign};
|
||||
line-height: ${readerSettings.lineHeight};
|
||||
font-family: "${readerSettings.fontFamily}";
|
||||
background-color: "${readerSettings.theme}";
|
||||
}
|
||||
hr {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
a {
|
||||
color: ${theme.primary};
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
epubUseCustomCSS
|
||||
? readerSettings.customCSS
|
||||
.replace(
|
||||
RegExp(`\#sourceId-${novel.pluginId}\\s*\\{`, 'g'),
|
||||
'body {',
|
||||
)
|
||||
.replace(
|
||||
RegExp(`\#sourceId-${novel.pluginId}[^\.\#A-Z]*`, 'gi'),
|
||||
'',
|
||||
)
|
||||
: ''
|
||||
}`,
|
||||
[novel, epubUseAppTheme, readerSettings, epubUseCustomCSS],
|
||||
);
|
||||
|
||||
const epubJS = useMemo(
|
||||
() =>
|
||||
`
|
||||
let novelName = "${novel.name}";
|
||||
let chapterName = "";
|
||||
let sourceId =${novel.pluginId};
|
||||
let chapterId ="";
|
||||
let novelId =${novel.id};
|
||||
let html = document.querySelector("chapter").innerHTML;
|
||||
|
||||
${readerSettings.customJS}
|
||||
`,
|
||||
[novel, readerSettings],
|
||||
);
|
||||
|
||||
const createEpub = async (uri: string) => {
|
||||
var epub = new EpubBuilder(
|
||||
{
|
||||
title: novel.name,
|
||||
fileName: novel.name.replace(/\s/g, ''),
|
||||
language: 'en',
|
||||
cover: novel.cover,
|
||||
description: novel.summary,
|
||||
author: novel.author,
|
||||
bookId: novel.pluginId.toString(),
|
||||
stylesheet: epubStyle || undefined,
|
||||
js: epubUseCustomJS ? epubJS : undefined,
|
||||
},
|
||||
uri,
|
||||
);
|
||||
console.log({
|
||||
title: novel.name,
|
||||
fileName: novel.name.replace(/\s/g, ''),
|
||||
language: 'en',
|
||||
cover: novel.cover,
|
||||
description: novel.summary,
|
||||
author: novel.author,
|
||||
bookId: novel.pluginId.toString(),
|
||||
stylesheet: epubStyle || undefined,
|
||||
js: epubUseCustomJS ? epubJS : undefined,
|
||||
});
|
||||
try {
|
||||
await epub.prepare();
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const chapter = chapters[i];
|
||||
const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`;
|
||||
if (await FileManager.exists(filePath)) {
|
||||
const downloaded = FileManager.readFile(filePath);
|
||||
console.log({
|
||||
title:
|
||||
chapter.name?.trim() ?? 'Chapter ' + (chapter.chapterNumber || i),
|
||||
fileName: 'Chapter' + i,
|
||||
htmlBody: `<chapter data-novel-id='${novel.pluginId}' data-chapter-id='${chapter.id}'>${downloaded}</chapter>`,
|
||||
});
|
||||
|
||||
await epub.addChapter({
|
||||
title:
|
||||
chapter.name?.trim() ?? 'Chapter ' + (chapter.chapterNumber || i),
|
||||
fileName: 'Chapter' + i,
|
||||
htmlBody: `<chapter data-novel-id='${novel.pluginId}' data-chapter-id='${chapter.id}'>${downloaded}</chapter>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
var epubFilePath = await epub.save();
|
||||
showToast('Epub file saved at: ' + epubFilePath);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('Cannot create because: ' + error);
|
||||
await epub.discardChanges();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon="book-arrow-down-outline"
|
||||
iconColor={theme.onBackground}
|
||||
size={21}
|
||||
onPress={showModal}
|
||||
/>
|
||||
<Portal>
|
||||
<ChooseEpubLocationModal
|
||||
isVisible={isVisible}
|
||||
hideModal={hideModal}
|
||||
onSubmit={createEpub}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default EpubIconButton;
|
||||
@@ -1,233 +1,160 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, Dimensions, StyleSheet } from 'react-native';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings, useTheme } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const NovelScreenLoading: React.FC<Props> = ({ theme }) => {
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
const styles = createStyleSheet();
|
||||
const LoadingShimmer = memo(
|
||||
({
|
||||
style,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
style?: any;
|
||||
height: number;
|
||||
width: number | string;
|
||||
}) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const theme = useTheme();
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
|
||||
const RenderLoadingNovelTop = () => {
|
||||
return (
|
||||
<View style={styles.novelTopContainer}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.picture}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={150}
|
||||
width={100}
|
||||
/>
|
||||
<View style={styles.novelTopText}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={25}
|
||||
width={240}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={20}
|
||||
width={240}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={20}
|
||||
width={240}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ShimmerPlaceHolder
|
||||
style={style}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={height}
|
||||
width={width}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const RenderLoadingNovelInformation = () => {
|
||||
return (
|
||||
<View style={styles.novelInformationContainer}>
|
||||
<View style={styles.icons}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.icon}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={56}
|
||||
width={90}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.icon}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={56}
|
||||
width={90}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.icon}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={56}
|
||||
width={90}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.novelInformationText}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={350}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={350}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.novelInformationChips}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.chip}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={32}
|
||||
width={60}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.chip}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={32}
|
||||
width={60}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const NovelTop = memo(() => (
|
||||
<View style={styles.headerContainer}>
|
||||
<LoadingShimmer style={styles.picture} height={150} width={100} />
|
||||
<View style={styles.headerText}>
|
||||
<LoadingShimmer style={styles.text} height={25} width={240} />
|
||||
<LoadingShimmer style={styles.text} height={20} width={240} />
|
||||
<LoadingShimmer style={styles.text} height={20} width={240} />
|
||||
</View>
|
||||
</View>
|
||||
));
|
||||
|
||||
const renderLoadingChapter = (item: number, index: number) => {
|
||||
return (
|
||||
<View style={styles.chapter} key={index}>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={20}
|
||||
width={350}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.text}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={350}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const NovelInformation = memo(() => (
|
||||
<View style={styles.metadataContainer}>
|
||||
<View style={styles.statsContainer}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<LoadingShimmer key={i} style={styles.icon} height={56} width={90} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.novelInformationText}>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<LoadingShimmer key={i} style={styles.text} height={16} width={350} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.novelInformationChips}>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<LoadingShimmer key={i} style={styles.chip} height={32} width={60} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
));
|
||||
|
||||
const RenderLoadingChapters = () => {
|
||||
const items = [1, 2, 3, 4, 5, 6, 7];
|
||||
return (
|
||||
<View style={styles.chapterContainer}>
|
||||
<ShimmerPlaceHolder
|
||||
style={[styles.text, { marginBottom: 5 }]}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={30}
|
||||
width={350}
|
||||
/>
|
||||
{items.map(renderLoadingChapter)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const ChapterItem = memo(() => (
|
||||
<View style={styles.chapter}>
|
||||
<LoadingShimmer style={styles.text} height={20} width={350} />
|
||||
<LoadingShimmer style={styles.text} height={16} width={350} />
|
||||
</View>
|
||||
));
|
||||
|
||||
const RenderLoading = () => {
|
||||
return (
|
||||
<>
|
||||
<RenderLoadingNovelTop />
|
||||
<RenderLoadingNovelInformation />
|
||||
<RenderLoadingChapters />
|
||||
</>
|
||||
);
|
||||
};
|
||||
const Chapters = memo(() => (
|
||||
<View style={styles.chapterContainer}>
|
||||
<LoadingShimmer
|
||||
style={[styles.text, { marginBottom: 5 }]}
|
||||
height={30}
|
||||
width={350}
|
||||
/>
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<ChapterItem key={i} />
|
||||
))}
|
||||
</View>
|
||||
));
|
||||
|
||||
const NovelScreenLoading: React.FC<Props> = () => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<RenderLoading />
|
||||
<NovelTop />
|
||||
<NovelInformation />
|
||||
<Chapters />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyleSheet = () => {
|
||||
return StyleSheet.create({
|
||||
novelTopContainer: {
|
||||
paddingTop: 118,
|
||||
height: 268,
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
},
|
||||
novelTopText: {
|
||||
height: 100,
|
||||
paddingTop: 30,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
novelInformationContainer: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
icons: {
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
icon: {
|
||||
borderRadius: 30,
|
||||
},
|
||||
novelInformationText: {
|
||||
margin: 16,
|
||||
marginTop: 8,
|
||||
height: 62,
|
||||
},
|
||||
novelInformationChips: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 6,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
chip: {
|
||||
marginLeft: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
chapterContainer: {
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
chapter: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
marginHorizontal: 4,
|
||||
marginBottom: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 4.8,
|
||||
width: 124.2,
|
||||
height: 229.1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
text: {
|
||||
borderRadius: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
loadingText: {
|
||||
margin: 10,
|
||||
height: 10,
|
||||
width: Dimensions.get('window').width - 140,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
picture: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
};
|
||||
const styles = StyleSheet.create({
|
||||
headerContainer: {
|
||||
paddingTop: 118,
|
||||
height: 268,
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
},
|
||||
headerText: {
|
||||
height: 100,
|
||||
paddingTop: 30,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
metadataContainer: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
icon: {
|
||||
borderRadius: 30,
|
||||
},
|
||||
novelInformationText: {
|
||||
margin: 16,
|
||||
marginTop: 8,
|
||||
height: 62,
|
||||
},
|
||||
novelInformationChips: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 6,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
chip: {
|
||||
marginLeft: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
chapterContainer: {
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
chapter: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
marginHorizontal: 4,
|
||||
marginBottom: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
text: {
|
||||
borderRadius: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
picture: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(NovelScreenLoading);
|
||||
|
||||
@@ -7,8 +7,12 @@ import Animated, {
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated';
|
||||
import EpubIconButton from './EpubIconButton';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
|
||||
const NovelAppbar = ({
|
||||
novel,
|
||||
chapters,
|
||||
theme,
|
||||
isLocal,
|
||||
downloadChapters,
|
||||
@@ -21,6 +25,8 @@ const NovelAppbar = ({
|
||||
showJumpToChapterModal,
|
||||
headerOpacity,
|
||||
}: {
|
||||
novel: NovelInfo;
|
||||
chapters: ChapterInfo[];
|
||||
theme: ThemeColors;
|
||||
isLocal: boolean;
|
||||
downloadChapters: (amount: number | 'all' | 'unread') => void;
|
||||
@@ -52,6 +58,7 @@ const NovelAppbar = ({
|
||||
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
|
||||
<Appbar.BackAction onPress={goBack} />
|
||||
<Appbar.Content title="" />
|
||||
<EpubIconButton theme={theme} novel={novel} chapters={chapters} />
|
||||
<Appbar.Action icon="share-variant" onPress={shareNovel} />
|
||||
<Appbar.Action
|
||||
icon="text-box-search-outline"
|
||||
|
||||
@@ -89,7 +89,10 @@ const TrackSheet = ({ bottomSheetRef, novel, theme }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet bottomSheetRef={bottomSheetRef} snapPoints={[130, 200]}>
|
||||
<BottomSheet
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
snapPoints={trackedNovel ? [180] : [130]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.contentContainer,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@components';
|
||||
import PickThemeStep from './PickThemeStep';
|
||||
import { useState } from 'react';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
enum OnboardingStep {
|
||||
PICK_THEME,
|
||||
@@ -25,7 +26,7 @@ export default function OnboardingScreen() {
|
||||
const renderHelptext = () => {
|
||||
switch (step) {
|
||||
case OnboardingStep.PICK_THEME:
|
||||
return 'Pick a theme';
|
||||
return getString('onboardingScreen.pickATheme');
|
||||
default:
|
||||
return <PickThemeStep />;
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export default function OnboardingScreen() {
|
||||
color: theme.onBackground,
|
||||
}}
|
||||
>
|
||||
Yaholo!
|
||||
{getString('onboardingScreen.welcome')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
@@ -72,7 +73,7 @@ export default function OnboardingScreen() {
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
title="Complete"
|
||||
title={getString('onboardingScreen.complete')}
|
||||
mode="contained"
|
||||
onPress={() => {
|
||||
MMKVStorage.set('IS_ONBOARDED', true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ScrollView } from 'react-native-gesture-handler';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { darkThemes, lightThemes } from '@theme/md3';
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
const ThemeList = ({
|
||||
theme,
|
||||
@@ -66,7 +67,9 @@ const SchemeButton = ({
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={{ color: isSelected ? theme.onPrimary : theme.onSurface }}>
|
||||
{isDarkButton ? 'Dark' : 'Light'}
|
||||
{isDarkButton
|
||||
? getString('onboardingScreen.dark')
|
||||
: getString('onboardingScreen.light')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
|
||||
const defaultValue = {} as {
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
setChapter: (chapter: ChapterInfo) => void;
|
||||
loading: boolean;
|
||||
setLoading: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const ChapterContext = createContext<{
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
setChapter: (chapter: ChapterInfo) => void;
|
||||
loading: boolean;
|
||||
setLoading: (value: boolean) => void;
|
||||
}>(defaultValue);
|
||||
|
||||
export function ChapterContextProvider({
|
||||
children,
|
||||
novel,
|
||||
initialChapter,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
novel: NovelInfo;
|
||||
initialChapter: ChapterInfo;
|
||||
}) {
|
||||
const [chapter, setChapter] = useState(initialChapter);
|
||||
const [loading, setLoading] = useState(true);
|
||||
return (
|
||||
<ChapterContext.Provider
|
||||
value={{ novel, chapter, setChapter, loading, setLoading }}
|
||||
>
|
||||
{children}
|
||||
</ChapterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useChapterContext = () => {
|
||||
const { novel, chapter, setChapter, loading, setLoading } =
|
||||
useContext(ChapterContext);
|
||||
return { novel, chapter, setChapter, loading, setLoading };
|
||||
};
|
||||
@@ -16,7 +16,7 @@ const ChapterLoadingScreen = () => {
|
||||
return (
|
||||
<View style={{ backgroundColor }}>
|
||||
<SkeletonLines
|
||||
containerMargin={padding + '%'}
|
||||
containerMargin={padding}
|
||||
containerHeight={'100%'}
|
||||
containerWidth={'100%'}
|
||||
color={
|
||||
|
||||
@@ -1,303 +1,108 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
NativeEventEmitter,
|
||||
DrawerLayoutAndroid,
|
||||
} from 'react-native';
|
||||
import * as Speech from 'expo-speech';
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { DrawerLayoutAndroid } from 'react-native';
|
||||
|
||||
import VolumeButtonListener from '@native/volumeButtonListener';
|
||||
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
|
||||
import {
|
||||
getNextChapter,
|
||||
getPrevChapter,
|
||||
markChapterRead,
|
||||
updateChapterProgress,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { fetchChapter } from '@services/plugin/fetch';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import {
|
||||
useChapterGeneralSettings,
|
||||
useLibrarySettings,
|
||||
useTheme,
|
||||
useTrackedNovel,
|
||||
useTracker,
|
||||
useNovel,
|
||||
} from '@hooks/persisted';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
import { useChapterGeneralSettings, useTheme } from '@hooks/persisted';
|
||||
|
||||
import ReaderAppbar from './components/ReaderAppbar';
|
||||
import ReaderFooter from './components/ReaderFooter';
|
||||
|
||||
import { insertHistory } from '@database/queries/HistoryQueries';
|
||||
import WebViewReader from './components/WebViewReader';
|
||||
import { useFullscreenMode } from '@hooks';
|
||||
import ReaderBottomSheetV2 from './components/ReaderBottomSheet/ReaderBottomSheet';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { sanitizeChapterText } from './utils/sanitizeChapterText';
|
||||
import ChapterDrawer from './components/ChapterDrawer';
|
||||
import ChapterLoadingScreen from './ChapterLoadingScreen/ChapterLoadingScreen';
|
||||
import { ErrorScreenV2 } from '@components';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import WebView from 'react-native-webview';
|
||||
import { getString } from '@strings/translations';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import KeepScreenAwake from './components/KeepScreenAwake';
|
||||
import useChapter from './hooks/useChapter';
|
||||
import { ChapterContextProvider, useChapterContext } from './ChapterContext';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
import { useBackHandler } from '@hooks/index';
|
||||
import { get } from 'lodash-es';
|
||||
|
||||
const Chapter = ({ route, navigation }: ChapterScreenProps) => {
|
||||
const drawerRef = useRef<DrawerLayoutAndroid>(null);
|
||||
const { chapters, novelSettings, pages, setLastRead, setPageIndex } =
|
||||
useNovel(route.params.novel.path, route.params.novel.pluginId);
|
||||
return (
|
||||
<DrawerLayoutAndroid
|
||||
ref={drawerRef}
|
||||
drawerWidth={300}
|
||||
drawerPosition="left"
|
||||
renderNavigationView={() => (
|
||||
<ChapterDrawer
|
||||
<ChapterContextProvider
|
||||
novel={route.params.novel}
|
||||
initialChapter={route.params.chapter}
|
||||
>
|
||||
<DrawerLayoutAndroid
|
||||
ref={drawerRef}
|
||||
onDrawerOpen={() => {
|
||||
drawerRef.current?.setState(prev => ({ ...prev, isOpen: true }));
|
||||
}}
|
||||
onDrawerClose={() => {
|
||||
drawerRef.current?.setState(prev => ({ ...prev, isOpen: false }));
|
||||
}}
|
||||
drawerWidth={300}
|
||||
drawerPosition="left"
|
||||
renderNavigationView={() => <ChapterDrawer />}
|
||||
>
|
||||
<ChapterContent
|
||||
route={route}
|
||||
navigation={navigation}
|
||||
chapters={chapters}
|
||||
novelSettings={novelSettings}
|
||||
pages={pages}
|
||||
setPageIndex={setPageIndex}
|
||||
drawerRef={drawerRef}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ChapterContent
|
||||
route={route}
|
||||
navigation={navigation}
|
||||
drawerRef={drawerRef}
|
||||
setLastRead={setLastRead}
|
||||
/>
|
||||
</DrawerLayoutAndroid>
|
||||
</DrawerLayoutAndroid>
|
||||
</ChapterContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
type ChapterContentProps = ChapterScreenProps & {
|
||||
drawerRef: React.RefObject<DrawerLayoutAndroid>;
|
||||
setLastRead: (chapter: ChapterInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const ChapterContent = ({
|
||||
route,
|
||||
navigation,
|
||||
drawerRef,
|
||||
setLastRead,
|
||||
}: ChapterContentProps) => {
|
||||
useKeepAwake();
|
||||
const { novel, chapter } = route.params;
|
||||
const { novel, chapter } = useChapterContext();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const readerSheetRef = useRef(null);
|
||||
|
||||
const readerSheetRef = useRef<BottomSheetModalMethods>(null);
|
||||
const theme = useTheme();
|
||||
const { pageReader = false, keepScreenOn } = useChapterGeneralSettings();
|
||||
|
||||
const {
|
||||
useVolumeButtons,
|
||||
autoScroll,
|
||||
autoScrollInterval,
|
||||
autoScrollOffset,
|
||||
// verticalSeekbar = true,
|
||||
removeExtraParagraphSpacing,
|
||||
} = useChapterGeneralSettings();
|
||||
const { incognitoMode } = useLibrarySettings();
|
||||
hidden,
|
||||
loading,
|
||||
error,
|
||||
prevChapter,
|
||||
nextChapter,
|
||||
chapterText,
|
||||
saveProgress,
|
||||
hideHeader,
|
||||
navigateChapter,
|
||||
refetch,
|
||||
} = useChapter(webViewRef);
|
||||
|
||||
const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode();
|
||||
|
||||
const [hidden, setHidden] = useState(true);
|
||||
|
||||
const { tracker } = useTracker();
|
||||
const { trackedNovel, updateNovelProgess } = useTrackedNovel(novel.id);
|
||||
|
||||
const [sourceChapter, setChapter] = useState({ ...chapter, chapterText: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [[nextChapter, prevChapter], setAdjacentChapter] = useState<
|
||||
ChapterInfo[]
|
||||
>([]);
|
||||
|
||||
const emmiter = useRef(new NativeEventEmitter(VolumeButtonListener));
|
||||
|
||||
const connectVolumeButton = () => {
|
||||
VolumeButtonListener.connect();
|
||||
VolumeButtonListener.preventDefault();
|
||||
emmiter.current.addListener('VolumeUp', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: -${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
const scrollToStart = () =>
|
||||
requestAnimationFrame(() => {
|
||||
webViewRef?.current?.injectJavaScript(
|
||||
!pageReader
|
||||
? `(()=>{
|
||||
window.scrollTo({top:0,behavior:'smooth'})
|
||||
})()`
|
||||
: `(()=>{
|
||||
document.querySelector('chapter').setAttribute('data-page',0);
|
||||
document.querySelector("chapter").style.transform = 'translate(0%)';
|
||||
})()`,
|
||||
);
|
||||
});
|
||||
emmiter.current.addListener('VolumeDown', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: ${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useVolumeButtons) {
|
||||
connectVolumeButton();
|
||||
} else {
|
||||
VolumeButtonListener.disconnect();
|
||||
emmiter.current.removeAllListeners('VolumeUp');
|
||||
emmiter.current.removeAllListeners('VolumeDown');
|
||||
// this is just for sure, without it app still works properly
|
||||
}
|
||||
return () => {
|
||||
VolumeButtonListener.disconnect();
|
||||
emmiter.current.removeAllListeners('VolumeUp');
|
||||
emmiter.current.removeAllListeners('VolumeDown');
|
||||
Speech.stop();
|
||||
};
|
||||
}, [useVolumeButtons, chapter]);
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
setTimeout(() => connectVolumeButton());
|
||||
}, []);
|
||||
|
||||
const getChapter = async () => {
|
||||
try {
|
||||
const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${chapter.novelId}/${chapter.id}/index.html`;
|
||||
if (await FileManager.exists(filePath)) {
|
||||
sourceChapter.chapterText = FileManager.readFile(filePath);
|
||||
} else {
|
||||
await fetchChapter(novel.pluginId, chapter.path)
|
||||
.then(res => {
|
||||
sourceChapter.chapterText = res;
|
||||
})
|
||||
.catch(e => setError(e.message));
|
||||
}
|
||||
const [nextChap, prevChap] = await Promise.all([
|
||||
getNextChapter(chapter.novelId, chapter.id),
|
||||
getPrevChapter(chapter.novelId, chapter.id),
|
||||
]);
|
||||
setChapter(sourceChapter);
|
||||
setAdjacentChapter([nextChap, prevChap]);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getChapter();
|
||||
if (!incognitoMode) {
|
||||
insertHistory(chapter.id);
|
||||
setLastRead(chapter);
|
||||
}
|
||||
}, [chapter]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offsetY: number) => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollTo({top:${offsetY},behavior:'smooth',})
|
||||
})()`);
|
||||
},
|
||||
[webViewRef],
|
||||
);
|
||||
|
||||
let scrollInterval: NodeJS.Timeout;
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollInterval = setInterval(() => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top:${defaultTo(
|
||||
autoScrollOffset,
|
||||
Dimensions.get('window').height,
|
||||
)},behavior:'smooth'})
|
||||
})()`);
|
||||
}, autoScrollInterval * 1000);
|
||||
} else {
|
||||
clearInterval(scrollInterval);
|
||||
}
|
||||
|
||||
return () => clearInterval(scrollInterval);
|
||||
}, [autoScroll, webViewRef]);
|
||||
|
||||
const updateTracker = () => {
|
||||
const chapterNumber = parseChapterNumber(novel.name, chapter.name);
|
||||
if (tracker && trackedNovel && chapterNumber > trackedNovel.progress) {
|
||||
updateNovelProgess(tracker, chapterNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = useCallback(
|
||||
(percentage: number) => {
|
||||
if (!incognitoMode) {
|
||||
updateChapterProgress(chapter.id, percentage > 100 ? 100 : percentage);
|
||||
}
|
||||
|
||||
if (!incognitoMode && percentage >= 97) {
|
||||
// a relative number
|
||||
markChapterRead(chapter.id);
|
||||
updateTracker();
|
||||
}
|
||||
},
|
||||
[chapter],
|
||||
);
|
||||
|
||||
const hideHeader = () => {
|
||||
if (!hidden) {
|
||||
webViewRef.current?.injectJavaScript('toolWrapper.hide()');
|
||||
setImmersiveMode();
|
||||
} else {
|
||||
webViewRef.current?.injectJavaScript('toolWrapper.show()');
|
||||
showStatusAndNavBar();
|
||||
}
|
||||
setHidden(!hidden);
|
||||
};
|
||||
|
||||
const navigateToChapterBySwipe = (actionName: string) => {
|
||||
let navChapter;
|
||||
if (actionName === 'SWIPE_LEFT') {
|
||||
navChapter = nextChapter;
|
||||
} else if (actionName === 'SWIPE_RIGHT') {
|
||||
navChapter = prevChapter;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
navChapter
|
||||
? navigation.replace('Chapter', {
|
||||
novel: novel,
|
||||
chapter: navChapter,
|
||||
})
|
||||
: showToast(
|
||||
actionName === 'SWIPE_LEFT'
|
||||
? getString('readerScreen.noNextChapter')
|
||||
: getString('readerScreen.noPreviousChapter'),
|
||||
);
|
||||
};
|
||||
|
||||
const chapterText = useMemo(
|
||||
() =>
|
||||
sanitizeChapterText(sourceChapter.chapterText, {
|
||||
removeExtraParagraphSpacing,
|
||||
}),
|
||||
[sourceChapter.chapterText],
|
||||
);
|
||||
|
||||
const openDrawer = useCallback(() => {
|
||||
drawerRef.current?.openDrawer();
|
||||
hideHeader();
|
||||
}, [drawerRef, hideHeader]);
|
||||
|
||||
if (loading) {
|
||||
return <ChapterLoadingScreen />;
|
||||
}
|
||||
useBackHandler(() => {
|
||||
if (get(drawerRef.current?.state, 'isOpen')) {
|
||||
drawerRef.current?.closeDrawer();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -307,11 +112,7 @@ export const ChapterContent = ({
|
||||
{
|
||||
iconName: 'refresh',
|
||||
title: getString('common.retry'),
|
||||
onPress: () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
getChapter();
|
||||
},
|
||||
onPress: refetch,
|
||||
},
|
||||
{
|
||||
iconName: 'earth',
|
||||
@@ -329,37 +130,30 @@ export const ChapterContent = ({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<WebViewReader
|
||||
data={{ novel, chapter }}
|
||||
html={chapterText}
|
||||
nextChapter={nextChapter}
|
||||
webViewRef={webViewRef}
|
||||
saveProgress={saveProgress}
|
||||
onLayout={() => {
|
||||
useVolumeButtons && onLayout();
|
||||
}}
|
||||
onPress={hideHeader}
|
||||
navigateToChapterBySwipe={navigateToChapterBySwipe}
|
||||
/>
|
||||
{keepScreenOn ? <KeepScreenAwake /> : null}
|
||||
{loading ? (
|
||||
<ChapterLoadingScreen />
|
||||
) : (
|
||||
<WebViewReader
|
||||
html={chapterText}
|
||||
nextChapter={nextChapter}
|
||||
webViewRef={webViewRef}
|
||||
saveProgress={saveProgress}
|
||||
onPress={hideHeader}
|
||||
navigateChapter={navigateChapter}
|
||||
/>
|
||||
)}
|
||||
<ReaderBottomSheetV2 bottomSheetRef={readerSheetRef} />
|
||||
{!hidden ? (
|
||||
<>
|
||||
<ReaderAppbar
|
||||
novelName={novel.name}
|
||||
chapter={sourceChapter}
|
||||
setChapter={setChapter}
|
||||
goBack={navigation.goBack}
|
||||
theme={theme}
|
||||
/>
|
||||
<ReaderAppbar goBack={navigation.goBack} theme={theme} />
|
||||
<ReaderFooter
|
||||
theme={theme}
|
||||
chapter={chapter}
|
||||
novel={novel}
|
||||
nextChapter={nextChapter}
|
||||
prevChapter={prevChapter}
|
||||
readerSheetRef={readerSheetRef}
|
||||
scrollTo={scrollTo}
|
||||
navigateToChapterBySwipe={navigateToChapterBySwipe}
|
||||
scrollToStart={scrollToStart}
|
||||
navigateChapter={navigateChapter}
|
||||
navigation={navigation}
|
||||
openDrawer={openDrawer}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,8 @@ import React from 'react';
|
||||
import { View, Pressable, TextStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import color from 'color';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '@navigators/types';
|
||||
import { StyleProp } from 'react-native';
|
||||
import { ViewStyle } from 'react-native';
|
||||
|
||||
@@ -15,32 +13,20 @@ type Styles = {
|
||||
chapterNameCtn: StyleProp<TextStyle>;
|
||||
releaseDateCtn: StyleProp<TextStyle>;
|
||||
};
|
||||
type Navigation = StackNavigationProp<RootStackParamList, 'Chapter', undefined>;
|
||||
|
||||
type Props = {
|
||||
item: ChapterInfo;
|
||||
novelItem: NovelInfo;
|
||||
styles: Styles;
|
||||
theme: ThemeColors;
|
||||
navigation: Navigation;
|
||||
chapterId: number;
|
||||
onPress: () => void;
|
||||
};
|
||||
const changeChapter = (
|
||||
item: ChapterInfo,
|
||||
navigation: Navigation,
|
||||
novelItem: NovelInfo,
|
||||
) => {
|
||||
navigation.replace('Chapter', {
|
||||
novel: novelItem,
|
||||
chapter: item,
|
||||
});
|
||||
};
|
||||
|
||||
const renderListChapter = ({
|
||||
item,
|
||||
novelItem,
|
||||
styles,
|
||||
theme,
|
||||
navigation,
|
||||
onPress,
|
||||
chapterId,
|
||||
}: Props) => {
|
||||
return (
|
||||
@@ -54,7 +40,7 @@ const renderListChapter = ({
|
||||
>
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
onPress={() => changeChapter(item, navigation, novelItem)}
|
||||
onPress={onPress}
|
||||
style={styles.chapterCtn}
|
||||
>
|
||||
<Text
|
||||
|
||||
@@ -12,18 +12,12 @@ import { FlashList, ViewToken } from '@shopify/flash-list';
|
||||
import { Button, LoadingScreenV2 } from '@components/index';
|
||||
import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelSettings } from '@hooks/persisted/useNovel';
|
||||
import { useNovel } from '@hooks/persisted/useNovel';
|
||||
import renderListChapter from './RenderListChapter';
|
||||
import { useChapterContext } from '@screens/reader/ChapterContext';
|
||||
|
||||
type ChapterDrawerProps = ChapterScreenProps & {
|
||||
chapters: ChapterInfo[];
|
||||
novelSettings: NovelSettings;
|
||||
pages: string[];
|
||||
setPageIndex: (value: number) => void;
|
||||
};
|
||||
type ButtonProperties = {
|
||||
text: string;
|
||||
index?: number;
|
||||
@@ -34,14 +28,17 @@ type ButtonsProperties = {
|
||||
down: ButtonProperties;
|
||||
};
|
||||
|
||||
const ChapterDrawer = ({
|
||||
route,
|
||||
navigation,
|
||||
chapters,
|
||||
novelSettings,
|
||||
pages,
|
||||
setPageIndex,
|
||||
}: ChapterDrawerProps) => {
|
||||
const ChapterDrawer = () => {
|
||||
const {
|
||||
novel: novelItem,
|
||||
chapter,
|
||||
setChapter,
|
||||
setLoading,
|
||||
} = useChapterContext();
|
||||
const { chapters, novelSettings, pages, setPageIndex } = useNovel(
|
||||
novelItem.path,
|
||||
novelItem.pluginId,
|
||||
);
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
@@ -49,7 +46,6 @@ const ChapterDrawer = ({
|
||||
|
||||
const styles = createStylesheet(theme, insets);
|
||||
|
||||
const { chapter, novel: novelItem } = route.params;
|
||||
const { sort = defaultChapterSort } = novelSettings;
|
||||
const listAscending = sort === 'ORDER BY position ASC';
|
||||
|
||||
@@ -143,14 +139,17 @@ const ChapterDrawer = ({
|
||||
ref={listRef}
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
extraData={chapter}
|
||||
renderItem={val =>
|
||||
renderListChapter({
|
||||
item: val.item,
|
||||
novelItem,
|
||||
styles,
|
||||
theme,
|
||||
navigation,
|
||||
chapterId: chapter.id,
|
||||
onPress: () => {
|
||||
setLoading(true);
|
||||
setChapter(val.item);
|
||||
},
|
||||
})
|
||||
}
|
||||
estimatedItemSize={60}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
|
||||
const KeepScreenAwake = () => {
|
||||
useKeepAwake();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default KeepScreenAwake;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StatusBar, StyleSheet, View } from 'react-native';
|
||||
import color from 'color';
|
||||
|
||||
@@ -6,24 +6,20 @@ import { Text } from 'react-native-paper';
|
||||
import { IconButtonV2 } from '../../../components';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { bookmarkChapter } from '@database/queries/ChapterQueries';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
|
||||
interface ReaderAppbarProps {
|
||||
novelName: string;
|
||||
chapter: ChapterInfo & { chapterText: string };
|
||||
theme: ThemeColors;
|
||||
goBack: () => void;
|
||||
setChapter: (chapter: ChapterInfo & { chapterText: string }) => void;
|
||||
}
|
||||
|
||||
const ReaderAppbar = ({
|
||||
novelName,
|
||||
chapter,
|
||||
goBack,
|
||||
theme,
|
||||
setChapter,
|
||||
}: ReaderAppbarProps) => {
|
||||
const ReaderAppbar = ({ goBack, theme }: ReaderAppbarProps) => {
|
||||
const { chapter, novel } = useChapterContext();
|
||||
const [bookmarked, setBookmarked] = useState(chapter.bookmark);
|
||||
useEffect(() => {
|
||||
setBookmarked(chapter.bookmark);
|
||||
}, [chapter]);
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
@@ -49,7 +45,7 @@ const ReaderAppbar = ({
|
||||
style={[styles.title, { color: theme.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{novelName}
|
||||
{novel.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.subtitle, { color: theme.onSurfaceVariant }]}
|
||||
@@ -59,15 +55,10 @@ const ReaderAppbar = ({
|
||||
</Text>
|
||||
</View>
|
||||
<IconButtonV2
|
||||
name={chapter.bookmark ? 'bookmark' : 'bookmark-outline'}
|
||||
name={bookmarked ? 'bookmark' : 'bookmark-outline'}
|
||||
size={24}
|
||||
onPress={() => {
|
||||
bookmarkChapter(chapter.id).then(() =>
|
||||
setChapter({
|
||||
...chapter,
|
||||
bookmark: !chapter.bookmark,
|
||||
}),
|
||||
);
|
||||
bookmarkChapter(chapter.id).then(() => setBookmarked(!bookmarked));
|
||||
}}
|
||||
color={theme.onSurface}
|
||||
theme={theme}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import React, { Ref, useMemo, useState } from 'react';
|
||||
import React, { RefObject, useMemo, useState } from 'react';
|
||||
import color from 'color';
|
||||
|
||||
import { BottomSheetView, BottomSheetModal } from '@gorhom/bottom-sheet';
|
||||
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import BottomSheet from '@components/BottomSheet/BottomSheet';
|
||||
import { useChapterGeneralSettings, useTheme } from '@hooks/persisted';
|
||||
import { SceneMap, TabBar, TabView } from 'react-native-tab-view';
|
||||
@@ -12,9 +12,11 @@ import ReaderSheetPreferenceItem from './ReaderSheetPreferenceItem';
|
||||
import TextSizeSlider from './TextSizeSlider';
|
||||
import ReaderThemeSelector from './ReaderThemeSelector';
|
||||
import ReaderTextAlignSelector from './ReaderTextAlignSelector';
|
||||
import ReaderLineHeight from './ReaderLineHeight';
|
||||
import ReaderValueChange from './ReaderValueChange';
|
||||
import ReaderFontPicker from './ReaderFontPicker';
|
||||
import { overlay } from 'react-native-paper';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
|
||||
const ReaderTab: React.FC = () => {
|
||||
return (
|
||||
@@ -22,7 +24,19 @@ const ReaderTab: React.FC = () => {
|
||||
<TextSizeSlider />
|
||||
<ReaderThemeSelector />
|
||||
<ReaderTextAlignSelector />
|
||||
<ReaderLineHeight />
|
||||
<ReaderValueChange
|
||||
label={getString('readerScreen.bottomSheet.lineHeight')}
|
||||
valueKey="lineHeight"
|
||||
/>
|
||||
<ReaderValueChange
|
||||
label={getString('readerScreen.bottomSheet.padding')}
|
||||
valueKey="padding"
|
||||
valueChange={2}
|
||||
min={0}
|
||||
max={50}
|
||||
decimals={0}
|
||||
unit="px"
|
||||
/>
|
||||
<ReaderFontPicker />
|
||||
</View>
|
||||
);
|
||||
@@ -31,6 +45,7 @@ const ReaderTab: React.FC = () => {
|
||||
const GeneralTab: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
keepScreenOn,
|
||||
fullScreenMode,
|
||||
autoScroll,
|
||||
verticalSeekbar,
|
||||
@@ -38,13 +53,15 @@ const GeneralTab: React.FC = () => {
|
||||
showScrollPercentage,
|
||||
useVolumeButtons,
|
||||
swipeGestures,
|
||||
pageReader = false,
|
||||
removeExtraParagraphSpacing,
|
||||
bionicReading,
|
||||
tapToScroll = false,
|
||||
setChapterGeneralSettings,
|
||||
} = useChapterGeneralSettings();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<BottomSheetScrollView showsVerticalScrollIndicator={false}>
|
||||
<ReaderSheetPreferenceItem
|
||||
label={getString('readerScreen.bottomSheet.fullscreen')}
|
||||
onPress={() =>
|
||||
@@ -93,6 +110,12 @@ const GeneralTab: React.FC = () => {
|
||||
value={swipeGestures}
|
||||
theme={theme}
|
||||
/>
|
||||
<ReaderSheetPreferenceItem
|
||||
label={getString('readerScreen.bottomSheet.pageReader')}
|
||||
onPress={() => setChapterGeneralSettings({ pageReader: !pageReader })}
|
||||
value={pageReader}
|
||||
theme={theme}
|
||||
/>
|
||||
<ReaderSheetPreferenceItem
|
||||
label={getString('readerScreen.bottomSheet.removeExtraSpacing')}
|
||||
onPress={() =>
|
||||
@@ -119,12 +142,26 @@ const GeneralTab: React.FC = () => {
|
||||
value={bionicReading}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
<ReaderSheetPreferenceItem
|
||||
label={getString('readerScreen.bottomSheet.tapToScroll')}
|
||||
onPress={() => setChapterGeneralSettings({ tapToScroll: !tapToScroll })}
|
||||
value={tapToScroll}
|
||||
theme={theme}
|
||||
/>
|
||||
<ReaderSheetPreferenceItem
|
||||
label={getString('readerScreen.bottomSheet.keepScreenOn')}
|
||||
onPress={() =>
|
||||
setChapterGeneralSettings({ keepScreenOn: !keepScreenOn })
|
||||
}
|
||||
value={keepScreenOn}
|
||||
theme={theme}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
interface ReaderBottomSheetV2Props {
|
||||
bottomSheetRef: Ref<BottomSheetModal> | null;
|
||||
bottomSheetRef: RefObject<BottomSheetModalMethods> | null;
|
||||
}
|
||||
|
||||
const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
|
||||
@@ -157,41 +194,45 @@ const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const renderTabBar = (props: any) => (
|
||||
<TabBar
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: theme.primary }}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: tabHeaderColor,
|
||||
borderBottomColor: theme.outline,
|
||||
borderBottomWidth: 0.5,
|
||||
},
|
||||
styles.tabBar,
|
||||
]}
|
||||
renderLabel={({ route, color }) => (
|
||||
<Text style={{ color }}>{route.title}</Text>
|
||||
)}
|
||||
inactiveColor={theme.onSurfaceVariant}
|
||||
activeColor={theme.primary}
|
||||
pressColor={color(theme.primary).alpha(0.12).string()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTabBar = (props: any) => {
|
||||
return (
|
||||
<TabBar
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: theme.primary }}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: tabHeaderColor,
|
||||
borderBottomColor: theme.outline,
|
||||
borderBottomWidth: 0.5,
|
||||
},
|
||||
styles.tabBar,
|
||||
]}
|
||||
renderLabel={({ route, color }) => (
|
||||
<Text style={{ color }}>{route.title}</Text>
|
||||
)}
|
||||
inactiveColor={theme.onSurfaceVariant}
|
||||
activeColor={theme.primary}
|
||||
pressColor={color(theme.primary).alpha(0.12).string()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return (
|
||||
<BottomSheet bottomSheetRef={bottomSheetRef} snapPoints={[360, 560]}>
|
||||
<BottomSheetView
|
||||
style={[styles.bottomSheetContainer, { backgroundColor }]}
|
||||
>
|
||||
<TabView
|
||||
navigationState={{ index, routes }}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{ width: layout.width }}
|
||||
style={styles.tabView}
|
||||
/>
|
||||
</BottomSheetView>
|
||||
<BottomSheet
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
snapPoints={[360, 600]}
|
||||
backgroundStyle={{ backgroundColor }}
|
||||
bottomInset={bottom}
|
||||
containerStyle={{ borderRadius: 8 }}
|
||||
>
|
||||
<TabView
|
||||
navigationState={{ index, routes }}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{ width: layout.width }}
|
||||
style={styles.tabView}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
@@ -199,11 +240,6 @@ const ReaderBottomSheetV2: React.FC<ReaderBottomSheetV2Props> = ({
|
||||
export default ReaderBottomSheetV2;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetContainer: {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
},
|
||||
tabView: {
|
||||
borderTopRightRadius: 8,
|
||||
borderTopLeftRadius: 8,
|
||||
|
||||
+38
-13
@@ -3,43 +3,66 @@ import React from 'react';
|
||||
|
||||
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
|
||||
import { IconButtonV2 } from '@components';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ChapterReaderSettings } from '@hooks/persisted/useSettings';
|
||||
|
||||
interface ReaderLineHeightProps {
|
||||
type ValueKey<T extends object> = {
|
||||
[K in keyof T]: T[K] extends number ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
interface ReaderValueChangeProps {
|
||||
labelStyle?: TextStyle | TextStyle[];
|
||||
valueChange?: number;
|
||||
label: string;
|
||||
valueKey: ValueKey<ChapterReaderSettings>;
|
||||
decimals?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const ReaderLineHeight: React.FC<ReaderLineHeightProps> = ({ labelStyle }) => {
|
||||
const ReaderValueChange: React.FC<ReaderValueChangeProps> = ({
|
||||
labelStyle,
|
||||
label,
|
||||
valueChange = 0.1,
|
||||
valueKey,
|
||||
decimals = 1,
|
||||
min = 1.3,
|
||||
max = 2,
|
||||
unit = '%',
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { lineHeight, setChapterReaderSettings } = useChapterReaderSettings();
|
||||
const { setChapterReaderSettings, ...settings } = useChapterReaderSettings();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[{ color: theme.onSurfaceVariant }, labelStyle]}>
|
||||
{getString('readerScreen.bottomSheet.lineHeight')}
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.buttonContainer}>
|
||||
<IconButtonV2
|
||||
name="minus"
|
||||
color={theme.primary}
|
||||
size={26}
|
||||
disabled={lineHeight <= 1.3}
|
||||
disabled={settings[valueKey] <= min}
|
||||
onPress={() =>
|
||||
setChapterReaderSettings({ lineHeight: lineHeight - 0.1 })
|
||||
setChapterReaderSettings({
|
||||
[valueKey]: Math.max(min, settings[valueKey] - valueChange),
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<Text style={[styles.value, { color: theme.onSurface }]}>
|
||||
{`${Math.round(lineHeight * 10) / 10}%`}
|
||||
{`${((settings[valueKey] * 10) / 10).toFixed(decimals)}${unit}`}
|
||||
</Text>
|
||||
<IconButtonV2
|
||||
name="plus"
|
||||
color={theme.primary}
|
||||
size={26}
|
||||
disabled={lineHeight >= 2}
|
||||
disabled={settings[valueKey] >= max}
|
||||
onPress={() =>
|
||||
setChapterReaderSettings({ lineHeight: lineHeight + 0.1 })
|
||||
setChapterReaderSettings({
|
||||
[valueKey]: Math.min(max, settings[valueKey] + valueChange),
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
@@ -48,7 +71,7 @@ const ReaderLineHeight: React.FC<ReaderLineHeightProps> = ({ labelStyle }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ReaderLineHeight;
|
||||
export default ReaderValueChange;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -59,7 +82,9 @@ const styles = StyleSheet.create({
|
||||
marginVertical: 6,
|
||||
},
|
||||
value: {
|
||||
paddingHorizontal: 24,
|
||||
width: 60,
|
||||
paddingHorizontal: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -4,36 +4,34 @@ import { IconButton } from 'react-native-paper';
|
||||
import color from 'color';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { useDeviceOrientation } from '@hooks/index';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
|
||||
interface ChapterFooterProps {
|
||||
theme: ThemeColors;
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
nextChapter: ChapterInfo;
|
||||
prevChapter: ChapterInfo;
|
||||
readerSheetRef: React.RefObject<BottomSheetModalMethods>;
|
||||
scrollTo: (offsetY: number) => void;
|
||||
navigateToChapterBySwipe: (actionName: string) => void;
|
||||
scrollToStart: () => void;
|
||||
navigateChapter(position: 'NEXT' | 'PREV'): void;
|
||||
navigation: ChapterScreenProps['navigation'];
|
||||
openDrawer: () => void;
|
||||
}
|
||||
|
||||
const ChapterFooter = ({
|
||||
theme,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
prevChapter,
|
||||
readerSheetRef,
|
||||
scrollTo,
|
||||
navigateToChapterBySwipe,
|
||||
scrollToStart,
|
||||
navigateChapter,
|
||||
navigation,
|
||||
openDrawer,
|
||||
}: ChapterFooterProps) => {
|
||||
const { novel, chapter } = useChapterContext();
|
||||
const rippleConfig = {
|
||||
color: theme.rippleColor,
|
||||
borderless: true,
|
||||
@@ -56,7 +54,7 @@ const ChapterFooter = ({
|
||||
<Pressable
|
||||
android_ripple={rippleConfig}
|
||||
style={styles.buttonStyles}
|
||||
onPress={() => navigateToChapterBySwipe('SWIPE_RIGHT')}
|
||||
onPress={() => navigateChapter('PREV')}
|
||||
>
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
@@ -83,7 +81,7 @@ const ChapterFooter = ({
|
||||
<Pressable
|
||||
android_ripple={rippleConfig}
|
||||
style={styles.buttonStyles}
|
||||
onPress={() => scrollTo(0)}
|
||||
onPress={() => scrollToStart()}
|
||||
>
|
||||
<IconButton
|
||||
icon="format-vertical-align-top"
|
||||
@@ -116,7 +114,7 @@ const ChapterFooter = ({
|
||||
<Pressable
|
||||
android_ripple={rippleConfig}
|
||||
style={styles.buttonStyles}
|
||||
onPress={() => navigateToChapterBySwipe('SWIPE_LEFT')}
|
||||
onPress={() => navigateChapter('NEXT')}
|
||||
>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { memo } from 'react';
|
||||
import { View, Dimensions, StyleSheet } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
const SkeletonLines = ({
|
||||
width,
|
||||
@@ -22,6 +23,7 @@ const SkeletonLines = ({
|
||||
color?: string;
|
||||
highlightColor?: string;
|
||||
}) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
const styles = createStyleSheet(
|
||||
containerWidth,
|
||||
@@ -77,6 +79,7 @@ const SkeletonLines = ({
|
||||
: randomNumber * skeletonWidth
|
||||
}
|
||||
height={skeletonHeight}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +92,7 @@ const SkeletonLines = ({
|
||||
shimmerColors={[color, highlightColor, color]}
|
||||
width={skeletonWidth}
|
||||
height={skeletonHeight}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { FC, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native';
|
||||
import WebView from 'react-native-webview';
|
||||
import color from 'color';
|
||||
|
||||
@@ -24,9 +19,8 @@ import {
|
||||
} from '@hooks/persisted/useSettings';
|
||||
import { getBatteryLevelSync } from 'react-native-device-info';
|
||||
import * as Speech from 'expo-speech';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { PLUGIN_STORAGE } from '@utils/Storages';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
|
||||
type WebViewPostEvent = {
|
||||
type: string;
|
||||
@@ -34,38 +28,42 @@ type WebViewPostEvent = {
|
||||
};
|
||||
|
||||
type WebViewReaderProps = {
|
||||
data: {
|
||||
novel: {
|
||||
pluginId: string;
|
||||
};
|
||||
chapter: ChapterInfo;
|
||||
};
|
||||
html: string;
|
||||
nextChapter: ChapterInfo;
|
||||
nextChapter?: ChapterInfo;
|
||||
webViewRef: React.RefObject<WebView>;
|
||||
saveProgress(percentage: number): void;
|
||||
onPress(): void;
|
||||
onLayout(): void;
|
||||
navigateToChapterBySwipe(name: string): void;
|
||||
navigateChapter(position: 'NEXT' | 'PREV'): void;
|
||||
};
|
||||
|
||||
const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
const {
|
||||
data: { chapter, novel },
|
||||
html,
|
||||
nextChapter,
|
||||
webViewRef,
|
||||
saveProgress,
|
||||
onPress,
|
||||
onLayout,
|
||||
navigateToChapterBySwipe,
|
||||
} = props;
|
||||
const assetsUriPrefix = useMemo(
|
||||
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
|
||||
[],
|
||||
);
|
||||
const { RNDeviceInfo } = NativeModules;
|
||||
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
|
||||
const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
|
||||
let dataPayload;
|
||||
try {
|
||||
dataPayload = JSON.parse(payload.nativeEvent.data);
|
||||
} catch (e) {}
|
||||
if (dataPayload) {
|
||||
if (dataPayload.type === 'console') {
|
||||
console.info(`[Console] ${JSON.stringify(dataPayload.msg, null, 2)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { RNDeviceInfo } = NativeModules;
|
||||
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
|
||||
|
||||
const assetsUriPrefix = __DEV__
|
||||
? 'http://localhost:8081/assets'
|
||||
: 'file:///android_asset';
|
||||
|
||||
const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
html,
|
||||
webViewRef,
|
||||
nextChapter,
|
||||
saveProgress,
|
||||
onPress,
|
||||
navigateChapter,
|
||||
}) => {
|
||||
const { novel, chapter } = useChapterContext();
|
||||
const theme = useTheme();
|
||||
const readerSettings = useMemo(
|
||||
() =>
|
||||
@@ -73,20 +71,13 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
initialChapterReaderSettings,
|
||||
[],
|
||||
);
|
||||
const {
|
||||
showScrollPercentage,
|
||||
swipeGestures,
|
||||
showBatteryAndTime,
|
||||
verticalSeekbar,
|
||||
bionicReading,
|
||||
} = useMemo(
|
||||
const chapterGeneralSettings = useMemo(
|
||||
() =>
|
||||
getMMKVObject<ChapterGeneralSettings>(CHAPTER_GENERAL_SETTINGS) ||
|
||||
initialChapterGeneralSettings,
|
||||
[],
|
||||
);
|
||||
const batteryLevel = useMemo(getBatteryLevelSync, []);
|
||||
const layoutHeight = Dimensions.get('window').height;
|
||||
const plugin = getPlugin(novel?.pluginId);
|
||||
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
|
||||
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
|
||||
@@ -96,16 +87,16 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
switch (key) {
|
||||
case CHAPTER_READER_SETTINGS:
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`reader.updateReaderSettings(${MMKVStorage.getString(
|
||||
`reader.readerSettings.val = ${MMKVStorage.getString(
|
||||
CHAPTER_READER_SETTINGS,
|
||||
)})`,
|
||||
)}`,
|
||||
);
|
||||
break;
|
||||
case CHAPTER_GENERAL_SETTINGS:
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`reader.updateGeneralSettings(${MMKVStorage.getString(
|
||||
`reader.generalSettings.val = ${MMKVStorage.getString(
|
||||
CHAPTER_GENERAL_SETTINGS,
|
||||
)})`,
|
||||
)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -115,7 +106,7 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
'RNDeviceInfo_batteryLevelDidChange',
|
||||
(level: number) => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`reader.updateBatteryLevel(${level})`,
|
||||
`reader.batteryLevel.val = ${level}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -124,7 +115,8 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
subscription.remove();
|
||||
mmkvListener.remove();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
@@ -134,18 +126,18 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
scalesPageToFit={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
javaScriptEnabled={true}
|
||||
onLayout={async () => onLayout()}
|
||||
onMessage={ev => {
|
||||
onMessage={(ev: { nativeEvent: { data: string } }) => {
|
||||
__DEV__ && onLogMessage(ev);
|
||||
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
|
||||
switch (event.type) {
|
||||
case 'hide':
|
||||
onPress();
|
||||
break;
|
||||
case 'next':
|
||||
navigateToChapterBySwipe('SWIPE_LEFT');
|
||||
navigateChapter('NEXT');
|
||||
break;
|
||||
case 'prev':
|
||||
navigateToChapterBySwipe('SWIPE_RIGHT');
|
||||
navigateChapter('PREV');
|
||||
break;
|
||||
case 'save':
|
||||
if (event.data && typeof event.data === 'number') {
|
||||
@@ -169,137 +161,97 @@ const WebViewReader: FC<WebViewReaderProps> = props => {
|
||||
case 'stop-speak':
|
||||
Speech.stop();
|
||||
break;
|
||||
case 'copy':
|
||||
if (event.data && typeof event.data === 'string') {
|
||||
Clipboard.setStringAsync(event.data).then(() => {
|
||||
showToast(getString('common.copiedToClipboard', { name: '' }));
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}}
|
||||
source={{
|
||||
baseUrl: plugin?.site,
|
||||
baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
|
||||
headers: plugin?.imageRequestInit?.headers,
|
||||
method: plugin?.imageRequestInit?.method,
|
||||
body: plugin?.imageRequestInit?.body,
|
||||
html: `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<style>
|
||||
:root {
|
||||
--StatusBar-currentHeight: ${StatusBar.currentHeight};
|
||||
--readerSettings-theme: ${readerSettings.theme};
|
||||
--readerSettings-padding: ${readerSettings.padding}%;
|
||||
--readerSettings-textSize: ${readerSettings.textSize}px;
|
||||
--readerSettings-textColor: ${readerSettings.textColor};
|
||||
--readerSettings-textAlign: ${readerSettings.textAlign};
|
||||
--readerSettings-lineHeight: ${readerSettings.lineHeight};
|
||||
--readerSettings-fontFamily: ${readerSettings.fontFamily};
|
||||
--theme-primary: ${theme.primary};
|
||||
--theme-onPrimary: ${theme.onPrimary};
|
||||
--theme-secondary: ${theme.secondary};
|
||||
--theme-tertiary: ${theme.tertiary};
|
||||
--theme-onTertiary: ${theme.onTertiary};
|
||||
--theme-onSecondary: ${theme.onSecondary};
|
||||
--theme-surface: ${theme.surface};
|
||||
--theme-surface-0-9: ${color(theme.surface)
|
||||
.alpha(0.9)
|
||||
.toString()};
|
||||
--theme-onSurface: ${theme.onSurface};
|
||||
--theme-surfaceVariant: ${theme.surfaceVariant};
|
||||
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
|
||||
--theme-outline: ${theme.outline};
|
||||
--theme-rippleColor: ${theme.rippleColor};
|
||||
--chapterCtn-height: ${layoutHeight - 140};
|
||||
}
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<link rel="stylesheet" href="${pluginCustomCSS}">
|
||||
<style>${readerSettings.customCSS}</style>
|
||||
<script>
|
||||
var initSettings = {
|
||||
showScrollPercentage: ${showScrollPercentage},
|
||||
swipeGestures: ${swipeGestures},
|
||||
showBatteryAndTime: ${showBatteryAndTime},
|
||||
verticalSeekbar: ${verticalSeekbar},
|
||||
bionicReading: ${bionicReading},
|
||||
}
|
||||
var batteryLevel = ${batteryLevel};
|
||||
var autoSaveInterval = 2222;
|
||||
var { NOVEL, CHAPTER } = ${JSON.stringify({
|
||||
NOVEL: novel,
|
||||
CHAPTER: chapter,
|
||||
})}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chapterCtn" onclick="reader.post({type:'hide'})">
|
||||
<chapter
|
||||
data-plugin-id='${novel.pluginId}'
|
||||
data-novel-id='${chapter.novelId}'
|
||||
data-chapter-id='${chapter.id}'
|
||||
>
|
||||
${html}
|
||||
</chapter>
|
||||
<div class="hidden" id="ToolWrapper">
|
||||
<div id="TTS-Controller"></div>
|
||||
<div id="ScrollBar"></div>
|
||||
</div>
|
||||
<div id="Image-Modal">
|
||||
<img id="Image-Modal-img">
|
||||
</div>
|
||||
<div id="reader-footer-wrapper">
|
||||
<div id="reader-footer">
|
||||
<div id="reader-battery" class="reader-footer-item"></div>
|
||||
<div id="reader-percentage" class="reader-footer-item"></div>
|
||||
<div id="reader-time" class="reader-footer-item"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infoText">
|
||||
${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}
|
||||
</div>
|
||||
${
|
||||
nextChapter
|
||||
? `<button class="nextButton" onclick="reader.post({type:'next'})">
|
||||
${getString('readerScreen.nextChapter', {
|
||||
name: nextChapter.name,
|
||||
})}
|
||||
</button>`
|
||||
: `<div class="infoText">
|
||||
${getString('readerScreen.noNextChapter')}
|
||||
</div>`
|
||||
}
|
||||
</body>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
<script src="${pluginCustomJS}"></script>
|
||||
<script>
|
||||
async function fn(){
|
||||
${readerSettings.customJS}
|
||||
// scroll to saved position
|
||||
reader.refresh();
|
||||
window.scrollTo({
|
||||
top: reader.chapterHeight * ${
|
||||
chapter.progress
|
||||
} / 100 - reader.layoutHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", fn);
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<style>
|
||||
:root {
|
||||
--StatusBar-currentHeight: ${StatusBar.currentHeight}px;
|
||||
--readerSettings-theme: ${readerSettings.theme};
|
||||
--readerSettings-padding: ${readerSettings.padding}px;
|
||||
--readerSettings-textSize: ${readerSettings.textSize}px;
|
||||
--readerSettings-textColor: ${readerSettings.textColor};
|
||||
--readerSettings-textAlign: ${readerSettings.textAlign};
|
||||
--readerSettings-lineHeight: ${readerSettings.lineHeight};
|
||||
--readerSettings-fontFamily: ${readerSettings.fontFamily};
|
||||
--theme-primary: ${theme.primary};
|
||||
--theme-onPrimary: ${theme.onPrimary};
|
||||
--theme-secondary: ${theme.secondary};
|
||||
--theme-tertiary: ${theme.tertiary};
|
||||
--theme-onTertiary: ${theme.onTertiary};
|
||||
--theme-onSecondary: ${theme.onSecondary};
|
||||
--theme-surface: ${theme.surface};
|
||||
--theme-surface-0-9: ${color(theme.surface)
|
||||
.alpha(0.9)
|
||||
.toString()};
|
||||
--theme-onSurface: ${theme.onSurface};
|
||||
--theme-surfaceVariant: ${theme.surfaceVariant};
|
||||
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
|
||||
--theme-outline: ${theme.outline};
|
||||
--theme-rippleColor: ${theme.rippleColor};
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="${pluginCustomCSS}">
|
||||
<style>${readerSettings.customCSS}</style>
|
||||
</head>
|
||||
<body class="${
|
||||
chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
<div id="LNReader-chapter">
|
||||
${html}
|
||||
</div>
|
||||
<div id="reader-ui"></div>
|
||||
</body>
|
||||
<script>
|
||||
var initialReaderConfig = ${JSON.stringify({
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: `${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}`,
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: nextChapter?.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/core.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
<script src="${pluginCustomJS}"></script>
|
||||
<script>
|
||||
${readerSettings.customJS}
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
getNextChapter,
|
||||
getPrevChapter,
|
||||
markChapterRead,
|
||||
updateChapterProgress,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { insertHistory } from '@database/queries/HistoryQueries';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import {
|
||||
useChapterGeneralSettings,
|
||||
useLibrarySettings,
|
||||
useNovel,
|
||||
useTrackedNovel,
|
||||
useTracker,
|
||||
} from '@hooks/persisted';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { fetchChapter } from '@services/plugin/fetch';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { sanitizeChapterText } from '../utils/sanitizeChapterText';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
import WebView from 'react-native-webview';
|
||||
import { useFullscreenMode } from '@hooks';
|
||||
import { Dimensions, NativeEventEmitter } from 'react-native';
|
||||
import VolumeButtonListener from '@native/volumeButtonListener';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
const emmiter = new NativeEventEmitter(VolumeButtonListener);
|
||||
|
||||
export default function useChapter(webViewRef: RefObject<WebView>) {
|
||||
const { novel, chapter, setChapter, loading, setLoading } =
|
||||
useChapterContext();
|
||||
const { setLastRead } = useNovel(novel.path, novel.pluginId);
|
||||
const [hidden, setHidden] = useState(true);
|
||||
const [chapterText, setChapterText] = useState('');
|
||||
const [[nextChapter, prevChapter], setAdjacentChapter] = useState<
|
||||
ChapterInfo[]
|
||||
>([]);
|
||||
const { autoScroll, autoScrollInterval, autoScrollOffset, useVolumeButtons } =
|
||||
useChapterGeneralSettings();
|
||||
const { incognitoMode } = useLibrarySettings();
|
||||
const [error, setError] = useState<string>();
|
||||
const { tracker } = useTracker();
|
||||
const { trackedNovel, updateNovelProgess } = useTrackedNovel(novel.id);
|
||||
const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode();
|
||||
|
||||
const connectVolumeButton = () => {
|
||||
VolumeButtonListener.connect();
|
||||
VolumeButtonListener.preventDefault();
|
||||
emmiter.addListener('VolumeUp', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: -${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
emmiter.addListener('VolumeDown', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: ${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useVolumeButtons) {
|
||||
connectVolumeButton();
|
||||
} else {
|
||||
VolumeButtonListener.disconnect();
|
||||
emmiter.removeAllListeners('VolumeUp');
|
||||
emmiter.removeAllListeners('VolumeDown');
|
||||
// this is just for sure, without it app still works properly
|
||||
}
|
||||
return () => {
|
||||
VolumeButtonListener.disconnect();
|
||||
emmiter.removeAllListeners('VolumeUp');
|
||||
emmiter.removeAllListeners('VolumeDown');
|
||||
Speech.stop();
|
||||
};
|
||||
}, [useVolumeButtons, chapter]);
|
||||
|
||||
const getChapter = async () => {
|
||||
try {
|
||||
const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${chapter.novelId}/${chapter.id}/index.html`;
|
||||
let text = '';
|
||||
if (await FileManager.exists(filePath)) {
|
||||
text = FileManager.readFile(filePath);
|
||||
} else {
|
||||
await fetchChapter(novel.pluginId, chapter.path)
|
||||
.then(res => {
|
||||
text = res;
|
||||
})
|
||||
.catch(e => setError(e.message));
|
||||
}
|
||||
setChapterText(
|
||||
sanitizeChapterText(novel.pluginId, novel.name, chapter.name, text),
|
||||
);
|
||||
const [nextChap, prevChap] = await Promise.all([
|
||||
getNextChapter(chapter.novelId, chapter.id),
|
||||
getPrevChapter(chapter.novelId, chapter.id),
|
||||
]);
|
||||
setAdjacentChapter([nextChap!, prevChap!]);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
let scrollInterval: NodeJS.Timeout;
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollInterval = setInterval(() => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top:${defaultTo(
|
||||
autoScrollOffset,
|
||||
Dimensions.get('window').height,
|
||||
)},behavior:'smooth'})
|
||||
})()`);
|
||||
}, autoScrollInterval * 1000);
|
||||
} else {
|
||||
clearInterval(scrollInterval);
|
||||
}
|
||||
|
||||
return () => clearInterval(scrollInterval);
|
||||
}, [autoScroll, webViewRef]);
|
||||
|
||||
const updateTracker = () => {
|
||||
const chapterNumber = parseChapterNumber(novel.name, chapter.name);
|
||||
if (tracker && trackedNovel && chapterNumber > trackedNovel.progress) {
|
||||
updateNovelProgess(tracker, chapterNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = useCallback(
|
||||
(percentage: number) => {
|
||||
if (!incognitoMode) {
|
||||
updateChapterProgress(chapter.id, percentage > 100 ? 100 : percentage);
|
||||
}
|
||||
|
||||
if (!incognitoMode && percentage >= 97) {
|
||||
// a relative number
|
||||
markChapterRead(chapter.id);
|
||||
updateTracker();
|
||||
}
|
||||
},
|
||||
[chapter],
|
||||
);
|
||||
|
||||
const hideHeader = () => {
|
||||
if (!hidden) {
|
||||
webViewRef.current?.injectJavaScript('reader.hidden.val = true');
|
||||
setImmersiveMode();
|
||||
} else {
|
||||
webViewRef.current?.injectJavaScript('reader.hidden.val = false');
|
||||
showStatusAndNavBar();
|
||||
}
|
||||
setHidden(!hidden);
|
||||
};
|
||||
|
||||
const navigateChapter = (position: 'NEXT' | 'PREV') => {
|
||||
let navChapter;
|
||||
if (position === 'NEXT') {
|
||||
navChapter = nextChapter;
|
||||
} else if (position === 'PREV') {
|
||||
navChapter = prevChapter;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navChapter) {
|
||||
setLoading(true);
|
||||
setChapter(navChapter);
|
||||
} else {
|
||||
showToast(
|
||||
position === 'NEXT'
|
||||
? getString('readerScreen.noNextChapter')
|
||||
: getString('readerScreen.noPreviousChapter'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getChapter().finally(() => setLoading(false));
|
||||
if (!incognitoMode) {
|
||||
insertHistory(chapter.id);
|
||||
setLastRead(chapter);
|
||||
}
|
||||
}, [chapter]);
|
||||
|
||||
const refetch = () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getChapter().finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return {
|
||||
hidden,
|
||||
chapter,
|
||||
nextChapter,
|
||||
prevChapter,
|
||||
error,
|
||||
loading,
|
||||
chapterText,
|
||||
setHidden,
|
||||
saveProgress,
|
||||
hideHeader,
|
||||
navigateChapter,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { getString } from '@strings/translations';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
interface Options {
|
||||
removeExtraParagraphSpacing?: boolean;
|
||||
}
|
||||
|
||||
export const sanitizeChapterText = (
|
||||
pluginId: string,
|
||||
novelName: string,
|
||||
chapterName: string,
|
||||
html: string,
|
||||
options?: Options,
|
||||
): string => {
|
||||
let text = sanitizeHtml(html, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
||||
@@ -19,6 +17,7 @@ export const sanitizeChapterText = (
|
||||
'div',
|
||||
'ol',
|
||||
'li',
|
||||
'title',
|
||||
]),
|
||||
allowedAttributes: {
|
||||
'img': ['src', 'class', 'id'],
|
||||
@@ -29,12 +28,12 @@ export const sanitizeChapterText = (
|
||||
},
|
||||
allowedSchemes: ['data', 'http', 'https', 'file'],
|
||||
});
|
||||
if (text) {
|
||||
if (options?.removeExtraParagraphSpacing) {
|
||||
text = text.replace(/<\s*br[^>]*>/gi, '\n').replace(/\n{2,}/g, '\n\n');
|
||||
}
|
||||
} else {
|
||||
text = getString('readerScreen.emptyChapterMessage');
|
||||
}
|
||||
return text;
|
||||
return (
|
||||
text ||
|
||||
getString('readerScreen.emptyChapterMessage', {
|
||||
pluginId,
|
||||
novelName,
|
||||
chapterName,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
['lastUpdatedAt', 'libraryScreen.bottomSheet.sortOrders.lastUpdated'],
|
||||
]);
|
||||
const {
|
||||
disableLoadingAnimations,
|
||||
updateLibraryOnLaunch,
|
||||
downloadNewChapters,
|
||||
onlyUpdateOngoingNovels,
|
||||
@@ -226,12 +227,28 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
</List.SubHeader>
|
||||
<SettingSwitch
|
||||
label={getString('generalSettingsScreen.disableHapticFeedback')}
|
||||
description={getString(
|
||||
'generalSettingsScreen.disableHapticFeedbackDescription',
|
||||
)}
|
||||
value={disableHapticFeedback}
|
||||
onPress={() =>
|
||||
setAppSettings({ disableHapticFeedback: !disableHapticFeedback })
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString('generalSettingsScreen.disableLoadingAnimations')}
|
||||
description={getString(
|
||||
'generalSettingsScreen.disableLoadingAnimationsDesc',
|
||||
)}
|
||||
value={disableLoadingAnimations}
|
||||
onPress={() =>
|
||||
setAppSettings({
|
||||
disableLoadingAnimations: !disableLoadingAnimations,
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
<DisplayModeModal
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Portal, Modal, overlay, TextInput } from 'react-native-paper';
|
||||
import {
|
||||
Portal,
|
||||
Modal,
|
||||
overlay,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
} from 'react-native-paper';
|
||||
import { RadioButton } from '@components/RadioButton/RadioButton';
|
||||
|
||||
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
|
||||
import { Voice, getAvailableVoicesAsync } from 'expo-speech';
|
||||
import { Voice } from 'expo-speech';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
|
||||
interface VoicePickerModalProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
voices: Voice[];
|
||||
}
|
||||
|
||||
const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
onDismiss,
|
||||
visible,
|
||||
voices,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [searchedVoices, setSearchedVoices] = useState<Voice[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { setChapterReaderSettings, tts } = useChapterReaderSettings();
|
||||
useEffect(() => {
|
||||
getAvailableVoicesAsync().then(res => {
|
||||
res.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
@@ -76,6 +78,13 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
keyExtractor={item => item.identifier || 'system'}
|
||||
estimatedItemSize={64}
|
||||
removeClippedSubviews={true}
|
||||
ListEmptyComponent={
|
||||
<ActivityIndicator
|
||||
size={24}
|
||||
style={{ marginTop: 16 }}
|
||||
color={theme.primary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>
|
||||
|
||||
@@ -19,13 +19,16 @@ const GeneralSettings: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
keepScreenOn = true,
|
||||
useVolumeButtons = false,
|
||||
verticalSeekbar = true,
|
||||
swipeGestures = false,
|
||||
pageReader = false,
|
||||
autoScroll = false,
|
||||
autoScrollInterval = 10,
|
||||
autoScrollOffset = null,
|
||||
bionicReading = false,
|
||||
tapToScroll = false,
|
||||
setChapterGeneralSettings,
|
||||
} = useChapterGeneralSettings();
|
||||
|
||||
@@ -40,8 +43,14 @@ const GeneralSettings: React.FC = () => {
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('generalSettings')}
|
||||
</List.SubHeader>
|
||||
|
||||
{/* will add this later xD */}
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.keepScreenOn')}
|
||||
value={keepScreenOn}
|
||||
onPress={() =>
|
||||
setChapterGeneralSettings({ keepScreenOn: !keepScreenOn })
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.verticalSeekbar')}
|
||||
description={getString('readerSettings.verticalSeekbarDesc')}
|
||||
@@ -75,6 +84,18 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.tapToScroll')}
|
||||
value={tapToScroll}
|
||||
onPress={() => setChapterGeneralSettings({ tapToScroll: !tapToScroll })}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.pageReader')}
|
||||
value={pageReader}
|
||||
onPress={() => setChapterGeneralSettings({ pageReader: !pageReader })}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.autoscroll')}
|
||||
value={autoScroll}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import ReaderTextAlignSelector from '@screens/reader/components/ReaderBottomSheet/ReaderTextAlignSelector';
|
||||
import ReaderTextSize from '../ReaderTextSize';
|
||||
import ReaderLineHeight from '@screens/reader/components/ReaderBottomSheet/ReaderLineHeight';
|
||||
import ReaderValueChange from '@screens/reader/components/ReaderBottomSheet/ReaderValueChange';
|
||||
import ReaderThemeSelector from '@screens/reader/components/ReaderBottomSheet/ReaderThemeSelector';
|
||||
import {
|
||||
presetReaderThemes,
|
||||
@@ -93,7 +93,21 @@ const ReaderThemeSettings = () => {
|
||||
) : null}
|
||||
<ReaderTextAlignSelector labelStyle={labelStyle} />
|
||||
<ReaderTextSize labelStyle={labelStyle} />
|
||||
<ReaderLineHeight labelStyle={labelStyle} />
|
||||
<ReaderValueChange
|
||||
labelStyle={labelStyle}
|
||||
label={getString('readerScreen.bottomSheet.lineHeight')}
|
||||
valueKey="lineHeight"
|
||||
/>
|
||||
<ReaderValueChange
|
||||
labelStyle={labelStyle}
|
||||
label={getString('readerScreen.bottomSheet.padding')}
|
||||
valueKey="padding"
|
||||
valueChange={2}
|
||||
min={0}
|
||||
max={50}
|
||||
decimals={0}
|
||||
unit="px"
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('readerScreen.bottomSheet.fontStyle')}
|
||||
description={currentFontName}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { List } from '@components';
|
||||
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
|
||||
import React from 'react';
|
||||
import { IconButtonV2, List } from '@components';
|
||||
import {
|
||||
useChapterGeneralSettings,
|
||||
useChapterReaderSettings,
|
||||
useTheme,
|
||||
} from '@hooks/persisted';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import VoicePickerModal from '../Modals/VoicePickerModal';
|
||||
import { useBoolean } from '@hooks';
|
||||
import { Portal } from 'react-native-paper';
|
||||
import { StyleSheet, View, Text } from 'react-native';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
|
||||
import Switch from '@components/Switch/Switch';
|
||||
|
||||
export default function TextToSpeechSettings() {
|
||||
const theme = useTheme();
|
||||
const { tts } = useChapterReaderSettings();
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
useEffect(() => {
|
||||
getAvailableVoicesAsync().then(res => {
|
||||
res.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
|
||||
const { TTSEnable = true, setChapterGeneralSettings } =
|
||||
useChapterGeneralSettings();
|
||||
const {
|
||||
value: voiceModalVisible,
|
||||
setTrue: showVoiceModal,
|
||||
@@ -15,19 +33,102 @@ export default function TextToSpeechSettings() {
|
||||
} = useBoolean();
|
||||
return (
|
||||
<>
|
||||
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
|
||||
<List.Item
|
||||
title={'TTS voice'}
|
||||
description={tts?.voice?.name || 'System'}
|
||||
onPress={showVoiceModal}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
|
||||
<View style={styles.row}>
|
||||
<Switch
|
||||
theme={theme}
|
||||
value={TTSEnable}
|
||||
onValueChange={() => {
|
||||
setChapterGeneralSettings({
|
||||
TTSEnable: !TTSEnable,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<IconButtonV2
|
||||
name="reload"
|
||||
theme={theme}
|
||||
color={theme.primary}
|
||||
onPress={() => {
|
||||
setChapterReaderSettings({
|
||||
tts: {
|
||||
pitch: 1,
|
||||
rate: 1,
|
||||
voice: { name: 'System', language: 'System' } as Voice,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{TTSEnable ? (
|
||||
<>
|
||||
<List.Item
|
||||
title={'TTS voice'}
|
||||
description={tts?.voice?.name || 'System'}
|
||||
onPress={showVoiceModal}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Section>
|
||||
<Text style={[styles.label, { color: theme.onSurface }]}>
|
||||
Voice rate
|
||||
</Text>
|
||||
<Slider
|
||||
style={styles.slider}
|
||||
value={tts?.rate}
|
||||
minimumValue={0.1}
|
||||
maximumValue={5}
|
||||
step={0.1}
|
||||
minimumTrackTintColor={theme.primary}
|
||||
maximumTrackTintColor={theme.surfaceVariant}
|
||||
thumbTintColor={theme.primary}
|
||||
onSlidingComplete={value =>
|
||||
setChapterReaderSettings({ tts: { ...tts, rate: value } })
|
||||
}
|
||||
/>
|
||||
<Text style={[styles.label, { color: theme.onSurface }]}>
|
||||
Voice pitch
|
||||
</Text>
|
||||
<Slider
|
||||
style={styles.slider}
|
||||
value={tts?.pitch}
|
||||
minimumValue={0.1}
|
||||
maximumValue={5}
|
||||
step={0.1}
|
||||
minimumTrackTintColor={theme.primary}
|
||||
maximumTrackTintColor={theme.surfaceVariant}
|
||||
thumbTintColor={theme.primary}
|
||||
onSlidingComplete={value =>
|
||||
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
|
||||
}
|
||||
/>
|
||||
</List.Section>
|
||||
</>
|
||||
) : null}
|
||||
<View style={{ height: 16 }} />
|
||||
<Portal>
|
||||
<VoicePickerModal
|
||||
visible={voiceModalVisible}
|
||||
onDismiss={hideVoiceModal}
|
||||
voices={voices}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
label: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
},
|
||||
slider: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, ScrollView, StatusBar, Dimensions } from 'react-native';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { View, ScrollView, StatusBar } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import WebView from 'react-native-webview';
|
||||
@@ -23,8 +23,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import color from 'color';
|
||||
import { useBatteryLevel } from 'react-native-device-info';
|
||||
import * as Speech from 'expo-speech';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import TextToSpeechSettings from './Settings/TextToSpeechSettings';
|
||||
|
||||
export type TextAlignments =
|
||||
@@ -47,65 +45,53 @@ const SettingsReaderScreen = () => {
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const novel = {
|
||||
'artist': null,
|
||||
'author': 'Kinugasa Shougo',
|
||||
'author': 'LNReader-kun',
|
||||
'cover':
|
||||
'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181',
|
||||
'genres': 'Drama,Slice of Life,Psychological,School Life,Shounen',
|
||||
'genres': 'Action,Hero',
|
||||
'id': 16,
|
||||
'inLibrary': 1,
|
||||
'isLocal': 0,
|
||||
'name': 'Classroom of the Elite (LN)',
|
||||
'path': 'novel/classroom-of-the-elite-16091321',
|
||||
'name': 'Preview Man (LN)',
|
||||
'path': 'novel/preview-man-16091321',
|
||||
'pluginId': 'lightnovelcave',
|
||||
'status': 'Ongoing',
|
||||
'summary':
|
||||
'Kōdo Ikusei Senior High School, a leading prestigious school with state-of-the-art facilities where nearly 100% of students go on to university or find employment. The students there have the freedom to wear any hairstyle and bring any personal effects they desire. Kōdo Ikusei is a paradise-like school, but the truth is that only the most superior of students receive favorable treatment.The protagonist Kiyotaka Ayanokōji is a student of D-class, which is where the school dumps its “inferior” students in order to ridicule them. For a certain reason, Kiyotaka was careless on his entrance examination, and was put in D-class. After meeting Suzune Horikita and Kikyō Kushida, two other students in his class, Kiyotaka’s situation begins to change.Show More',
|
||||
'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More',
|
||||
'totalPages': 8,
|
||||
};
|
||||
const chapter = {
|
||||
'bookmark': 0,
|
||||
'chapterNumber': 2.1,
|
||||
'chapterNumber': 1,
|
||||
'id': 3722,
|
||||
'isDownloaded': 1,
|
||||
'name': 'Chapter V4C2.1 - A Vast Array of Thoughts Part 1',
|
||||
'name': 'Chapter 1 - The rise of Preview Man',
|
||||
'novelId': 16,
|
||||
'page': '2',
|
||||
'path': 'novel/classroom-of-the-elite-547/vol-4-chapter-2-1',
|
||||
'path': 'novel/preview-man/chapter-1',
|
||||
'position': 0,
|
||||
'progress': 3,
|
||||
'readTime': '2024-06-08 22:56:09',
|
||||
'releaseTime': '14 tháng 9 năm 2021',
|
||||
'readTime': '2100-01-01 00:00:00',
|
||||
'releaseTime': 'January 1, 2100',
|
||||
'unread': 1,
|
||||
'updatedTime': null,
|
||||
};
|
||||
const [hidden, setHidden] = useState(true);
|
||||
const layoutHeight = Dimensions.get('window').height;
|
||||
const batteryLevel = useBatteryLevel();
|
||||
const readerSettings = useChapterReaderSettings();
|
||||
const {
|
||||
showScrollPercentage,
|
||||
showBatteryAndTime,
|
||||
verticalSeekbar,
|
||||
bionicReading,
|
||||
} = useChapterGeneralSettings();
|
||||
const chapterGeneralSettings = useChapterGeneralSettings();
|
||||
const READER_HEIGHT = 280;
|
||||
const assetsUriPrefix = useMemo(
|
||||
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
|
||||
[],
|
||||
);
|
||||
const dummyChapterInfo = {
|
||||
sourceId: 11,
|
||||
chapterId: 99,
|
||||
novelId: 20,
|
||||
novelName: 'novel name',
|
||||
chapterName: "chapter' name",
|
||||
};
|
||||
const webViewCSS = `
|
||||
<style>
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<style>
|
||||
:root {
|
||||
--StatusBar-currentHeight: ${StatusBar.currentHeight};
|
||||
--readerSettings-theme: ${readerSettings.theme};
|
||||
--readerSettings-padding: ${readerSettings.padding}%;
|
||||
--readerSettings-padding: ${readerSettings.padding}px;
|
||||
--readerSettings-textSize: ${readerSettings.textSize}px;
|
||||
--readerSettings-textColor: ${readerSettings.textColor};
|
||||
--readerSettings-textAlign: ${readerSettings.textAlign};
|
||||
@@ -124,21 +110,25 @@ const SettingsReaderScreen = () => {
|
||||
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
|
||||
--theme-outline: ${theme.outline};
|
||||
--theme-rippleColor: ${theme.rippleColor};
|
||||
--chapterCtn-height: ${layoutHeight - 140};
|
||||
}
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${readerSettings.fontFamily}.ttf");
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<style>
|
||||
${readerSettings.customCSS}
|
||||
</style>
|
||||
|
||||
<style>${readerSettings.customCSS}</style>
|
||||
`;
|
||||
|
||||
const readerBackgroundColor = readerSettings.theme;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Speech.stop();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Appbar
|
||||
@@ -158,14 +148,18 @@ const SettingsReaderScreen = () => {
|
||||
javaScriptEnabled={true}
|
||||
style={{ backgroundColor: readerBackgroundColor }}
|
||||
nestedScrollEnabled={true}
|
||||
onMessage={ev => {
|
||||
onMessage={(ev: { nativeEvent: { data: string } }) => {
|
||||
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
|
||||
switch (event.type) {
|
||||
case 'hide':
|
||||
if (hidden) {
|
||||
webViewRef.current?.injectJavaScript('toolWrapper.show()');
|
||||
webViewRef.current?.injectJavaScript(
|
||||
'reader.hidden.val = true',
|
||||
);
|
||||
} else {
|
||||
webViewRef.current?.injectJavaScript('toolWrapper.hide()');
|
||||
webViewRef.current?.injectJavaScript(
|
||||
'reader.hidden.val = false',
|
||||
);
|
||||
}
|
||||
setHidden(!hidden);
|
||||
break;
|
||||
@@ -186,15 +180,6 @@ const SettingsReaderScreen = () => {
|
||||
case 'stop-speak':
|
||||
Speech.stop();
|
||||
break;
|
||||
case 'copy':
|
||||
if (event.data && typeof event.data === 'string') {
|
||||
Clipboard.setStringAsync(event.data).then(() => {
|
||||
showToast(
|
||||
getString('common.copiedToClipboard', { name: '' }),
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}}
|
||||
source={{
|
||||
@@ -203,59 +188,44 @@ const SettingsReaderScreen = () => {
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
${webViewCSS}
|
||||
<script async>
|
||||
var initSettings = {
|
||||
showScrollPercentage: ${showScrollPercentage},
|
||||
swipeGestures: false,
|
||||
showBatteryAndTime: ${showBatteryAndTime},
|
||||
verticalSeekbar: ${verticalSeekbar},
|
||||
bionicReading: ${bionicReading},
|
||||
}
|
||||
var batteryLevel = ${batteryLevel};
|
||||
var autoSaveInterval = 2222;
|
||||
var { NOVEL, CHAPTER } = ${JSON.stringify({
|
||||
NOVEL: novel,
|
||||
CHAPTER: chapter,
|
||||
})}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<chapter
|
||||
data-novel-id='${dummyChapterInfo.novelId}'
|
||||
data-chapter-id='${dummyChapterInfo.chapterId}'
|
||||
onclick="reader.post({type:'hide'})"
|
||||
>
|
||||
${dummyHTML}
|
||||
</chapter>
|
||||
<div class="hidden" id="ToolWrapper">
|
||||
<div id="TTS-Controller"></div>
|
||||
<div id="ScrollBar"></div>
|
||||
<body class="${
|
||||
chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
<div id="LNReader-chapter">
|
||||
${dummyHTML}
|
||||
</div>
|
||||
<div id="Image-Modal">
|
||||
<img id="Image-Modal-img">
|
||||
</div>
|
||||
<div id="reader-footer-wrapper">
|
||||
<div id="reader-footer">
|
||||
<div id="reader-battery" class="reader-footer-item"></div>
|
||||
<div id="reader-percentage" class="reader-footer-item"></div>
|
||||
<div id="reader-time" class="reader-footer-item"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
<script>
|
||||
async function fn(){
|
||||
let novelName = "${dummyChapterInfo.novelName}";
|
||||
let chapterName = "${dummyChapterInfo.chapterName}";
|
||||
let sourceId =${dummyChapterInfo.sourceId};
|
||||
let chapterId =${dummyChapterInfo.chapterId};
|
||||
let novelId =${dummyChapterInfo.novelId};
|
||||
let html = document.getElementsByTagName("chapter")[0].innerHTML;
|
||||
${readerSettings.customJS}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", fn);
|
||||
</script>
|
||||
<div id="reader-ui"></div>
|
||||
</body>
|
||||
<script>
|
||||
var initialReaderConfig = ${JSON.stringify({
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter: chapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: `${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}`,
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: chapter.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/core.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
<script>
|
||||
${readerSettings.customJS}
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
}}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -65,7 +65,7 @@ const SettingsBrowseScreen = () => {
|
||||
ListEmptyComponent={
|
||||
<EmptyView
|
||||
icon="Σ(ಠ_ಠ)"
|
||||
description={getString('categories.emptyMsg')}
|
||||
description={getString('repositories.emptyMsg')}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { RefreshControl, SectionList, StyleSheet, Text } from 'react-native';
|
||||
|
||||
@@ -19,8 +19,9 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import { deleteChapter } from '@database/queries/ChapterQueries';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
import { UpdateScreenProps } from '@navigators/types';
|
||||
|
||||
const UpdatesScreen = () => {
|
||||
const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
isLoading,
|
||||
@@ -76,6 +77,20 @@ const UpdatesScreen = () => {
|
||||
}, [downloadQueue]),
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
if (navigation.isFocused()) {
|
||||
e.preventDefault();
|
||||
|
||||
navigation.navigate('MoreStack', {
|
||||
screen: 'Downloads',
|
||||
});
|
||||
}
|
||||
}),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchbarV2
|
||||
|
||||
@@ -3,16 +3,18 @@ import { StyleSheet, View } from 'react-native';
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import getLoadingColors from '@utils/getLoadingColors';
|
||||
import useLoadingColors from '@utils/useLoadingColors';
|
||||
import { useAppSettings } from '@hooks/persisted/index';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const UpdatesSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const ShimmerPlaceHolder = createShimmerPlaceholder(LinearGradient);
|
||||
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const renderLoadingChapter = (item: number, index: number) => {
|
||||
return (
|
||||
@@ -22,6 +24,7 @@ const UpdatesSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={42}
|
||||
width={42}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<View>
|
||||
<ShimmerPlaceHolder
|
||||
@@ -29,12 +32,14 @@ const UpdatesSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={16}
|
||||
width={257.5}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
<ShimmerPlaceHolder
|
||||
style={styles.textBottom}
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={12}
|
||||
width={257.5}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonCtn}>
|
||||
@@ -43,6 +48,7 @@ const UpdatesSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
shimmerColors={[backgroundColor, highlightColor, backgroundColor]}
|
||||
height={25}
|
||||
width={25}
|
||||
stopAutoRun={disableLoadingAnimations}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user