Compare commits

...

67 Commits

Author SHA1 Message Date
Rajarshee Chatterjee ecb3875fe2 chore: Update Translations (#1317)
* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (German)
[ci skip]
2024-12-01 21:11:44 +05:30
nyagami 2c9df1d93f more useful empty chapter message 2024-11-27 20:46:58 +07:00
nyagami 5db79a09eb fix: missing history navigation 2024-11-26 23:39:10 +07:00
Adam Kemish ff771fdd68 Better bottom navigation (#1335)
* Better bottom navigation

* Add double press actions to the rest of the tabs

* Fix history screen double press

* Fix lint error

* Fix more lint errors 😢

* 😢

* memo
2024-11-26 23:09:49 +07:00
Nil Silva 169c4b9de1 Add 'multi' language option (#1314)
* Added plugin settings

* Get plugin settings from plugin.

* Added multi language option

* Translate multi string

---------

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
2024-11-26 23:08:04 +07:00
nyagami 4bcd22b9de fix: margin for flatlist container not content container 2024-11-26 22:44:54 +07:00
nyagami 8a2dc15da6 fix: novel status 2024-11-26 22:39:56 +07:00
nyagami 3f3a407820 closes #1263; fix: invoke old refreshChapters 2024-11-26 22:06:20 +07:00
nyagami c72808d72f closes #1322; fix: tracking sheet position 2024-11-26 21:21:29 +07:00
nyagami dabe4710bb Fix: reader page padding & back press (#1339)
* fix: remove bottom padding in page reader

* fix: disable drag transition when holding

* reader bottom sheet backpress handler

* close drawer on backpress

* fix: undefined index

* chores: added onboading strings
2024-11-26 20:47:58 +07:00
Adam Kemish cdc4ddcc97 perf: Small changes following faster global search pr (#1320)
* Small changes following faster global search pr

* Fix lint error
2024-11-25 22:17:18 +07:00
Rajarshee Chatterjee 8f783fd4c2 refactor: Miscellaneous Cleanup (Closes #1307) 2024-11-10 11:12:08 +05:30
Rajarshee Chatterjee 048119e530 chore: Update Translations (#1308)
* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Russian)
[ci skip]
2024-11-09 08:14:52 +05:30
nyagami b68d44d500 refactor: Remove Unused Translations (#1316) 2024-11-09 08:13:00 +05:30
Rider21 4bc3638b38 link fixing (#1302)
* test

* Update downloadChapter.ts

* react-native-url-polyfill

* -urlencode

* oops

* +urlencode

* fix prettier

* wrong
2024-11-06 21:07:22 +07:00
Rajarshee Chatterjee bc5e2f1fd1 fix: Use onSurface Color for Chapter Names (Closes #1259) 2024-11-05 09:23:41 +05:30
Rajarshee Chatterjee e8e7519c40 fix: Show Empty Search State When No Results in Available Tab 2024-11-05 08:39:19 +05:30
Rajarshee Chatterjee 5460047e41 fix: Use Safe Area Insets for WebView Screen (Closes #1301) 2024-11-05 08:16:53 +05:30
Rajarshee Chatterjee bdd00cd87d perf: Add Lazy Loading for All Screens 2024-11-05 08:12:35 +05:30
Rajarshee Chatterjee f83c017e09 fix: fix: Display Loading Animation on Novel Screen 2024-11-05 08:07:31 +05:30
Rajarshee Chatterjee e38cb9972f chore: Update Translations (#1305)
* New translations strings.json (Romanian)
[ci skip]

* New translations strings.json (French)
[ci skip]

* New translations strings.json (Spanish)
[ci skip]

* New translations strings.json (Afrikaans)
[ci skip]

* New translations strings.json (Arabic)
[ci skip]

* New translations strings.json (Catalan)
[ci skip]

* New translations strings.json (Czech)
[ci skip]

* New translations strings.json (Danish)
[ci skip]

* New translations strings.json (German)
[ci skip]

* New translations strings.json (Greek)
[ci skip]

* New translations strings.json (Finnish)
[ci skip]

* New translations strings.json (Hebrew)
[ci skip]

* New translations strings.json (Hungarian)
[ci skip]

* New translations strings.json (Italian)
[ci skip]

* New translations strings.json (Japanese)
[ci skip]

* New translations strings.json (Korean)
[ci skip]

* New translations strings.json (Dutch)
[ci skip]

* New translations strings.json (Norwegian)
[ci skip]

* New translations strings.json (Polish)
[ci skip]

* New translations strings.json (Portuguese)
[ci skip]

* New translations strings.json (Russian)
[ci skip]

* New translations strings.json (Albanian)
[ci skip]

* New translations strings.json (Serbian (Cyrillic))
[ci skip]

* New translations strings.json (Swedish)
[ci skip]

* New translations strings.json (Turkish)
[ci skip]

* New translations strings.json (Ukrainian)
[ci skip]

* New translations strings.json (Chinese Simplified)
[ci skip]

* New translations strings.json (Chinese Traditional)
[ci skip]

* New translations strings.json (Vietnamese)
[ci skip]

* New translations strings.json (Portuguese, Brazilian)
[ci skip]

* New translations strings.json (Indonesian)
[ci skip]

* New translations strings.json (Hindi)
[ci skip]

* New translations strings.json (Assamese)
[ci skip]

* New translations strings.json (Odia)
[ci skip]

* Update crowdin.yml
2024-11-04 20:21:22 +05:30
Rajarshee Chatterjee ac9c789a89 chore: Update Translations 2024-11-04 20:09:48 +05:30
Adam Kemish 445187cf11 perf: Optimize Global Search Performance (#1255)
* Faster and less laggy global search

- Memoise each row in the search results page
- Limit number of concurrent searches (has setting, defaults to 5 sources at once)
- Load sources in the same order as they show in menu so from the user's pov it loads in faster
- Cancel search when u close global search
- Made the order of sources that are still loading follow the sorted order so theres less popin
- Added setting to remove animations from the loading placeholders
- Pause global search if u open a sub window (eg opening a novel)
- Change no results color to look less active

* Cancel previous global search when starting another

* Dont clear global search on unfocus
2024-11-04 18:30:11 +05:30
Rajarshee Chatterjee 52ec11a33d refactor: Prevent Multiple Database Connections 2024-11-04 17:30:36 +05:30
Palloxin c498d7e9cd feat: Increase Transparency of Android Navigation Bar (#1267)
Makes it more transparent in the reader mode.
2024-10-30 21:36:48 +05:30
Nil Silva 0fad9b907f feat: Add User Input Support for Plugin Configurations (#1273)
* Added plugin settings

* Get plugin settings from plugin.
2024-10-30 21:36:00 +05:30
Adam Kemish 0a170f14bc fix: Adjust Cover Height for Screen Width Changes (#1295) 2024-10-30 21:32:21 +05:30
Adam Kemish 8245e77672 feat: Add Switch and TextInput Support to Plugin Filters (#1271)
* Add FilterTypes.Switch support to plugin filters

* Add FilterTypes.TextInput support to plugin filters
2024-10-12 15:39:49 +05:30
nguyd1 9c68260592 fix: Resolve 'Can't Parse Page' SQL Syntax Error (#1266)
fix cant parse page sql syntax error
2024-10-04 17:24:37 +05:30
Palloxin b1778c5802 chore: Genericize Preview Novel Details (#1239) 2024-09-05 10:54:10 +05:30
Justin COLLON be4ed6de79 setEnvFile: fix error when no .env file (#1237) 2024-09-01 17:33:14 +07:00
Skeltons 3597b542d3 Fix: novel info not saving (#1232) 2024-08-27 20:44:56 +07:00
Palloxin fd6d912391 Disable user-select on next-button and info-text (#1225)
Disable user-select on next-button and info-text
2024-08-21 23:01:54 +07:00
Rider21 d9dc18da43 fix: download files with relative url (#1216)
* hard fix

* test

* Revert "test"

This reverts commit 1fe9247752fcbadf348b6017f2d1fbf05165cfce.

* error handler

[URL](https://github.com/facebook/react-native/blob/v0.72.10/packages/react-native/Libraries/Blob/URL.js)
2024-08-21 21:25:39 +07:00
Skeltons a22d754f95 feat: Reduce Sensitivity of Swiping for Plugins/Sources (#1220)
* Enhancement: Make swiping of plugins/sources less sensitive

* Update swiping offset to be less aggressive
2024-08-17 22:15:52 +05:30
Skeltons d9d7d3aaf0 feat: Reduce Sensitivity of Swiping for Plugins/Sources (#1219) 2024-08-17 15:15:09 +05:30
S.M.A Sayem Prodhan a399927264 fix: Resolve Search Error with Query Containing ' (#1133)
* fix search error when query contains '

* change search to prepared statement

* added missing semicolons
2024-08-17 15:12:12 +05:30
Palloxin b4b00a5c66 fix: Improve Progress Bar Proportions (#1197)
Progress bar better proportions
2024-08-17 15:10:04 +05:30
Justin COLLON 9861f81589 fix: Infinite Plugin Updates in Development Mode (#1217)
* re fix setEnvFile.cjs

* infinit plugin update if in dev mode
2024-08-17 15:09:08 +05:30
Skeltons d06aecb5f8 feat: Sort Source List Alphabetically (#1218) 2024-08-17 15:07:30 +05:30
nyagami 86c82c1a61 Fix reader bugs (#1212)
* fix: tts algorithm

* update: turn off tts by default

* update default padding

* change tts transition to 500ms

* update reader body padding

* cleanup

* fix: throw Exception
2024-08-15 23:07:01 +07:00
nyagami 1131446ddc Improvemed bottomsheets (#1195)
* smooth scroll behavior for reader bottom sheet

* fix: BottomSheet flatlist

* add bottom inset for reader bottom sheet
2024-08-11 16:15:15 +07:00
nyagami f6b2516643 Feat: rewrite Reader in vanjs (#1194)
* setup van.js

* scrollbar

* Reader Footer

* move state to reader object

* scroll to previous progress

* image modal

* TTS bubble

* swipe gestures

* binoic reading & remove extra spacing

* tap to scroll

* tap & scroll to move page

* page reader move chapter

* display page in footer

* hide progress bar in page reader mode

* remember postion when switch reader mode

* refactor: move initial scroll out of scrollbar

* scroll bar in page reader mode

* chapter ending content

* fix: tts controller

* fix: tts highlight & stopping

* optional tts

* custom js

* update preview reader

* cleanup

* fix: css
2024-08-11 10:37:28 +07:00
Palloxin 7cf8692e77 fix: Reduce Font Size of Reader Progress Bar (#1153)
From 16px to 14px.
It's better looking.
2024-07-19 11:12:46 +05:30
nyagami d6290294f7 Improvement: not using replace screen to navigate chpater 2024-07-17 21:58:38 +07:00
nyagami d5cb0a234f improvement: simplify shared chapter states with react context 2024-07-16 23:11:41 +07:00
nyagami 306d8d1d42 Improvement: move chapter logic to useChapter (almost) 2024-07-16 22:04:48 +07:00
nyagami 7b1eb7dd9d Improvement: Optimise available plugins by flashlist 2024-07-15 23:42:51 +07:00
nyagami 1ad2c98473 Feat: search plugin with id 2024-07-15 22:39:03 +07:00
nyagami f0b8df93b4 Fix: better download chapter error handling
at least
2024-07-15 22:30:11 +07:00
nyagami 19ce7da32e Feat: voice rate/pitch settings 2024-07-15 21:47:33 +07:00
nyagami 9d35f6d303 minor: allow title tag 2024-07-15 20:44:13 +07:00
Patrick Loser 31d0aa1787 feat: Add Horizontal Reading Mode (#1021)
* Horizontal Reading Mode (#733)

Closes #29
Closes #149

* fixed first few compatibility issues

* added WebView debugging help

* fixed pages logic + open next chapter at end

* hopefully fixed infinity glitch

* removed logs

* fixed small issues

* working page numbering

* double L shape navigation

* changed let to const

* fixed scrollTo function

* cleanup and fixed css issues

* fixed display bug

* fixed settings bug

* added change padding setting

* added strings

* save + scroll on opening chapter logic

* resolved totalpages detection issue

* simplified getInt function

* css cleanup

* removed useless

* fixed padding scaling bug

* deleted debug items

* additional top padding

* smaller chapter height

* fixed contextMenu

* replaced name: readerPages » pageReader

* fixed ReaderSettings Preview Reader behaviour

* cleanup

* ReaderValueChange improvements

* moved page state to js

* fixed type issues

* functionality test

* fix: NaN page on scrollbar

---------

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
2024-07-14 18:10:38 +05:30
nyagami ea5fde4156 Fix: add check multiplicable tasks & repace download queue by task queue 2024-07-14 00:42:52 +07:00
Rajarshee Chatterjee 03e3816ab8 fix: Optimize Marking Chapters as Read or Unread 2024-07-13 20:10:50 +05:30
Justin COLLON 9d6e407c40 fix: Retain Existing Data in .env File (#1130)
* setEnvFile: keeps data from .env file

* prettier for scripts
2024-07-13 18:37:32 +05:30
Batorian 6781b5d6a4 fix: Display Filter Label if Present (#1134)
filter: fix filters
2024-07-13 18:34:30 +05:30
Palloxin 352913f00e fix: Improve Image Placement in Preview (#1094)
* Better place the image in preview

The preview has to.. preview decently:
1. the image is too big (screen space taken)
2. in raw-chapter-text mode the img link is HUGE and one has to scroll a lot.

* Better place the image preview V2
2024-07-13 18:20:32 +05:30
Rajarshee Chatterjee 73f7ebf7b5 fix: Reset Chapter Progress When Marked as Unread (Closes #678) 2024-07-13 13:16:24 +05:30
Rajarshee Chatterjee 045d6419d8 fix: Fix Default Chapter Sorting (Closes #1060) 2024-07-13 12:59:35 +05:30
Rajarshee Chatterjee acc9d8e7ed feat: Pre-fill Edit Information with Existing Data and Add a Reset Option
Closes #1081
Closes #960
2024-07-13 12:52:47 +05:30
Rajarshee Chatterjee a29cce9eba feat: Add Description for "Disable haptic feedback" (Closes #1057) 2024-07-13 12:25:40 +05:30
Rajarshee Chatterjee 1292f520bb feat: Export as EPUB (Closes #1137) 2024-07-13 12:19:02 +05:30
Rajarshee Chatterjee 8bd6131862 feat: Add Option for "Keep screen on" (Closes #1137) 2024-07-13 11:06:29 +05:30
Rajarshee Chatterjee ed6c2b7ba2 feat: Add Empty State in Available Plugins Tab 2024-07-13 10:53:25 +05:30
nyagami f68a7a4e6a Fix: legacy restore 2024-07-12 10:07:50 +07:00
nyagami c9d2ce382f [skip ci] Dev improvement: Different configs for debug app 2024-07-12 09:29:53 +07:00
147 changed files with 17173 additions and 8724 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
**/node_modules
android
android/**/js/van.js
android/**/js/text-vibe.js
+1
View File
@@ -7,4 +7,5 @@ module.exports = {
trailingComma: 'all',
arrowParens: 'avoid',
quoteProps: 'preserve',
endOfLine: 'auto', // stop prettier from getting mad on windows
};
+1
View File
@@ -1,4 +1,5 @@
import 'react-native-gesture-handler';
import 'react-native-url-polyfill/auto';
import { enableFreeze } from 'react-native-screens';
enableFreeze(true);
+2
View File
@@ -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>
+70 -142
View File
@@ -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;
}
+471
View File
@@ -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;
});
})();
+6
View File
@@ -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
View File
@@ -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;
}
+312 -619
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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
+4187 -5359
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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"
},
+21
View File
@@ -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 }) => {
+25 -6
View File
@@ -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>
);
};
+1
View File
@@ -60,6 +60,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
},
text: {
textAlign: 'center',
marginTop: 16,
},
buttonWrapper: {
+2 -2
View File
@@ -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;
+5 -6
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
+36 -3
View File
@@ -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 -2
View File
@@ -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';
+7 -5
View File
@@ -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 -3
View File
@@ -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 -4
View File
@@ -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 -3
View File
@@ -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
+1 -1
View File
@@ -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);
+5 -3
View File
@@ -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]);
+22 -10
View File
@@ -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]);
+2 -1
View File
@@ -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) {
+21 -1
View File
@@ -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 = () => {
+15 -6
View File
@@ -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
View File
@@ -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 }}>
+2 -2
View File
@@ -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} />
+8 -3
View File
@@ -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,
-2
View File
@@ -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 },
+2
View File
@@ -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
+13 -1
View File
@@ -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 (
<>
+190 -112
View File
@@ -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>
@@ -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,
},
});
+18 -3
View File
@@ -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({
+14 -2
View File
@@ -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>
+29 -6
View File
@@ -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);
}
}}
/>
+7
View File
@@ -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}
+1 -1
View File
@@ -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,
},
});
+57 -28
View File
@@ -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,
+4 -3
View File
@@ -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);
+4 -1
View File
@@ -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>
+44
View File
@@ -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={
+79 -285
View File
@@ -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;
+11 -20
View File
@@ -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,
@@ -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',
+10 -12
View File
@@ -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 {
+129 -177
View File
@@ -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>
`,
}}
/>
);
+217
View File
@@ -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,
};
}
+12 -13
View File
@@ -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}
/>
}
+17 -2
View File
@@ -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