Compare commits

...

128 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
nyagami 5262567d61 Minor: hide reload button for custom page novels 2024-07-07 07:30:11 +07:00
nyagami b45a30f0cc Fix: avoid re-render in novel screen 2024-07-07 07:20:47 +07:00
nyagami 867b63af22 Feat: Plugin Custom JS/CSS 2024-07-06 11:42:18 +07:00
nyagami b3a5e237f4 added default cover 2024-07-06 11:41:29 +07:00
nyagami 7aae3a81c3 Fix: inlibrary badge 2024-07-06 11:32:25 +07:00
nyagami 078796784b Feat: Service Manager (#1124)
* feat: ServiceManager & import multiple epub files

* Fix: merge chapter based on index of toc in file list

* notifcation when jobs get done

* Fix epub: only read table of content in navmap

* add update library to service manager

* add drive backup/restore to service manager

* add self host backup/restore to service manager

* add migrate novel to service manager

* add download chapter to service manager

* clean up
2024-07-05 23:32:10 +07:00
Patrick Loser 15aed61aa6 Feat: tailing skeletons (#1064)
* tailing loading covers

* fix type issues

* fixed compatibility issues

* show skeleton even if row complete

* cleanup

* replaced !! with Boolean()

* Update type names

Co-authored-by: nyagami <hoangquan05112002@gmail.com>

* updated type occurances

* fixed key issue

* deleted unused vars and comments

---------

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
2024-07-05 23:31:53 +07:00
Justin COLLON b67d6f8088 fix: update defaultCover URL (#1122) 2024-06-30 02:14:04 +05:30
nyagami 9b4bdfea89 Merge epub chapters (#1117)
* Fix: ignore non existed files

* merge chapters & scan cover

* remove unused tocMap

* clean code
2024-06-27 16:04:50 +07:00
r1di 70220a5a87 Fix: Volume scrolling (#1114)
* Volume scrolling fix

the scrolling by volume button right now scrolls 100% of the screen, which hides text when scrolled so, the commit is just changing the the scrolling to 50%.

* typo

* 50% to 75% scroll

---------

Co-authored-by: Ridi <ridi.culous.progger@gmail.com>
2024-06-25 20:48:10 +07:00
Patrick Loser aa95743d1c Fix: hide progressbar on opening drawer (#1102) 2024-06-25 20:20:47 +07:00
nyagami eb818ef55b add website to about page 2024-06-23 17:37:35 +07:00
nyagami 0bda5fd055 No default repo 2024-06-23 17:37:29 +07:00
Patrick Loser ea336902f2 Fix: Scroll to current Chapter (#1101)
* cleanup + fixed scroll to current Chapter

* removed unused memoization

* removed unused import

* ChapterDrawer.tsx => index.tsx

* added correct types

* applied suggestion

* changed chapter.position to scrollIndex + 2

* refactored index.ts
2024-06-14 23:17:22 +07:00
nyagami 58a633e5b9 [skip ci] ignore prettier for android folder 2024-06-13 00:39:19 +07:00
nyagami 74ed5a446d Fix: background color for plugin icon 2024-06-13 00:17:41 +07:00
nyagami a5d073a1ec Fix: Adding Category/Repo buttons position 2024-06-12 23:45:51 +07:00
nyagami f4d3d9d4eb Fix: navigation bar content color after closing reader 2024-06-12 23:40:51 +07:00
nyagami 772a9ef3a2 Feat: Plugin lazy init(#1104)
* move plugins to single folder

* lazy initial

* clean up

* fix: restore novels
2024-06-12 16:26:55 +07:00
Patrick Loser 6f3278f6ff Fix: Allow id and class attributes (#1066)
added exceptions to sanitizeHtml

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
2024-06-10 20:26:34 +07:00
Adam Kemish f11a10ba22 Feat: Add open random entry button (#1098)
* Add open random entry button

* Move menu logic to searchbarv2
2024-06-10 20:20:54 +07:00
Justin COLLON 02304fd394 [skip ci] version increase for default plugins repo(#1074) 2024-06-09 12:18:05 +07:00
nyagami 94d11a12ff Fix: detect gzip images 2024-06-09 10:42:08 +07:00
nyagami fca37868f1 Feat: NOVEL and CHAPTER reader context 2024-06-08 23:01:07 +07:00
nyagami 574f54a9bc Fix: update novel logic for custom page 2024-06-08 22:12:38 +07:00
nyagami fb0649f07f Minor fix: update novel toast 2024-06-08 22:06:12 +07:00
nyagami e3def3821e Fix: Repository add button position 2024-06-08 21:44:44 +07:00
nyagami 334c981013 Fix: add imageRequestInit for Reader instead of fetching base64 2024-06-08 21:42:07 +07:00
nyagami b62c8e3aea Download file with cookie persisted from RN (#1100)
* download file

* clean up
2024-06-08 20:15:41 +07:00
nyagami 09a482e4b7 Fix: do not modify available plugins 2024-06-08 01:57:51 +07:00
nyagami b7bbbbe962 Fix: revert to app storage 2024-06-08 01:42:47 +07:00
Elfing 30265471b5 [skip ci] Adding support for French localization (#1092)
synchro Text translate
2024-06-04 21:35:02 +07:00
nyagami b4b038dd42 fix: use back button instead of previous 2024-06-04 19:37:45 +07:00
nyagami 6517028cd4 minor fix: more header & error on first time launching 2024-06-04 19:28:21 +07:00
nyagami 3f62f6f225 Feat: Permissions requesting (#1096)
fix: app permissions & remove restore errors
2024-06-04 18:52:51 +07:00
nyagami 822256229f Refactor: convert Java to Kotlin (#1095)
* kotlin migrating

* fix: kotlin generations

* fix: unzip & cleanup
2024-06-04 14:14:03 +07:00
nyagami 4f23e1814e Feat: Onboarding screen & FileManager (#1093)
* base onboard screen

* pick theme step

* cleanup

* refactor: TextFile -> FileManager

* pick storage folder

* default root storage

* refactor: fixed storage -> custom storage

* replace rnfs by native module FileManager

* clean up

* refactor: corresponding theme list
2024-06-03 21:21:46 +07:00
Justin COLLON aec6c34b66 Update: Version utils & Github actions (#1072)
* github action:
 - custom apk:
   - packet name with git commit id
   - app name with git commit id
 - cached `npm install`
 - update actions versions

* About page:
 - Version based on package.json
 - added git hash to the version if not Stable
 - RELEASE_DATE, GIT_HASH, BUILD_TYPE created at build time from setEnvFile.cjs
 - special BUILD_TYPE for github actions
 - Version modal copy on click
 - removed versionUtils

* expo-clipboard instead of react-native clipboard

* buildRelease Release instead of Debug ^^'
Gihtub Actions => Github Action
2024-05-31 00:00:07 +07:00
Rider21 e50b6c4a47 Feat: Plugin storages (#986)
* test

* fix

* Update pluginManager.ts

* test

* fix lint

* test

* test webview

* sync

* /

* Update WebviewScreen.tsx

* CookieManager

* Update storage.ts

* Update сookie.ts

* fix title

* MarqueeText...

* Slow

* Even slower

* Squashed commit of the following:

commit da4d7e878aced6567b98fc802c34715b27dfbe00
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Fri Mar 8 13:21:19 2024 +0300

    /

commit 592c4dbfca2d04931ffffba19d1eec20db3ac183
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Fri Mar 8 13:20:34 2024 +0300

    test

commit 63240502d67cda6e851d602728b873e0b6d2aed0
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Fri Mar 8 12:38:13 2024 +0300

    /

commit 591d2592a7f64e65da4f2c61216b564f3eafa2e6
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Fri Mar 8 11:42:03 2024 +0300

    fix progressbar

commit 3d34cfb72827fdcf61267eafbb128b9e893ef40f
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 21:13:49 2024 +0300

    forgot

commit c36d090a60eadfcc4bee1f0cae04bf9213a694a4
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 20:51:31 2024 +0300

    /

commit 3c4e43b7bb6f845d94737aa8cba96b0dc1714b4b
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 20:15:38 2024 +0300

    /

commit 0a20a483968fa42d24996a1d74f4c8e91f86d49a
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 19:46:31 2024 +0300

    add zIndex

commit 7d4490110457f1ae189890268731fc1726a87825
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 19:27:32 2024 +0300

    test

commit b585d5ee0a6d36707ddc323547fdd1976451ee5a
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 18:28:49 2024 +0300

    /

commit cb7241c33d6cea3c1493f41b46fdd67f70455731
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 18:06:30 2024 +0300

    Update Appbar.tsx

commit 5fab766e17708a1be6a9f53029918cad122110bb
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 17:55:24 2024 +0300

    Update WebviewScreen.tsx

commit aec9b92d033156936b97a8455f6198fa20570906
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 17:15:16 2024 +0300

    xd

commit 1fa385b4bd85bea4da34ff9c23021fb92bc622d9
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 17:11:23 2024 +0300

    2

commit 589870de27cee2fcf29c894bd055a936bb0e7a5d
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 16:28:24 2024 +0300

    1

commit 343d2dd5b64404a3ea54d8e0366d8c856c9ea429
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 16:11:08 2024 +0300

    test

commit f2dbf34a7962b04cee46fdb37a9d0ea83ca541fe
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Thu Mar 7 14:36:48 2024 +0300

    test

commit 04c04d97a9dd87815c36145b3e0bd63e1bb3d118
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Wed Mar 6 22:37:45 2024 +0300

    test zoom

commit 6b80559e053a0d1a31f4c4762570b894bb5f70e5
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Wed Mar 6 22:33:55 2024 +0300

    fix

commit 287ce94efc18036ff9cca3054e9ca182137ffe8c
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Wed Mar 6 19:00:01 2024 +0300

    test

commit 67bdddc4d10920b559122f3d7df0225cea65497b
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Wed Mar 6 11:10:22 2024 +0300

    Test newArch

commit 762353788716341474832815f332ad6ff9ebe5be
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Tue Mar 5 20:49:29 2024 +0300

    Update Appbar.tsx

commit 2a876cb942847fb4c67085a6736b2d9e6e9a3012
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Tue Mar 5 20:42:28 2024 +0300

    modal

commit ba4cadec3f87d565fe045e3c977f604444a06571
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Tue Mar 5 16:50:38 2024 +0300

    1

commit 6c14a18b7083fc3292f796408e2defdad2728b10
Author: Rider21 <58046032+Rider21@users.noreply.github.com>
Date:   Tue Mar 5 14:54:27 2024 +0300

    test

* fix

* fix

* Update pluginManager.ts

* test

* fix

* fix lint

* -marquee

* fix defaultCover

* test

* Update src/plugins/helpers/storage.ts

Co-authored-by: nyagami <hoangquan05112002@gmail.com>

* test

* Typescript fixes

* private pluginID

---------

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
2024-05-28 15:50:24 +07:00
nyagami 1ac6d116a2 Fix: Reader footer position & system bar (#1076)
* replace native Navigation bar by expo-navigation-bar

* fix fullscreen logic

* fix: navigation button color

* fix: navigation color when theme changed

* fix: status bar color on full screen mode

* 40 is too large

* no 40 is acceptable :)

* refactor setBarColor

* refactor: rename setStatusBarColor

* no padding for landscape mode
2024-05-28 15:13:46 +07:00
K1ngfish3r 6864d07dd9 Remove html entity replace function when sanitizing text (#1049)
* add spacing between <>

* remove check entirely

* fix ol tag
2024-05-28 11:21:36 +05:30
Lukas Richter 671e4f4bfd Fix: updateNovelTotalPages query (#1069) 2024-05-16 23:29:06 +07:00
nyagami e2d22a5b05 enhance TTS (#1054)
* fix: read by paragraph and empty string

* add voice picker modal

* add tts voice picker for preview

* fix: tts for bionic reading

* stop speak when get out of reader

* search voice

* temporary fix: original font

* fix: start & resume

* fix: close context menu on copy
2024-05-01 00:29:53 +07:00
Rajarshee Chatterjee 3cb2eb6b11 Make Reader Appbar and Footer Animation Smoother 2024-04-30 21:09:03 +05:30
Rajarshee Chatterjee 4597a95e9c Prevent Crash on Promise Rejection 2024-04-30 20:19:28 +05:30
Rajarshee Chatterjee cf4d97e7f9 Use Promise.allSettled and Update Plugin Repository Regex 2024-04-30 20:13:57 +05:30
Rajarshee Chatterjee 09f7319f2f feat: Repositories (#1051)
* feat: Repositories

* feat: Repositories

* Cleanup

* Cleanup

* Cleanup
2024-04-28 18:38:52 +05:30
Rajarshee Chatterjee c77dc52f29 Tweak UX (#1052)
* Tweak UX

* Prevent Crash in Migrate Screen

* Cleanup
2024-04-28 18:38:30 +05:30
Rajarshee Chatterjee 3cd5b40c25 Show Chapter Titles By Default 2024-04-28 09:37:49 +05:30
Rajarshee Chatterjee 42570d29f3 Move Update Icon Outside of Swipe Actions 2024-04-28 09:29:42 +05:30
nyagami 7e7a8866ca Fix: update novel pages (#1048)
* refreshPage button

* no latestChapter

* no hasUpdate in novel page
2024-04-27 09:44:54 +07:00
K1ngfish3r ed5e599edb add table style (#1020) 2024-04-26 00:16:46 +07:00
Patrick Loser addcadeba2 Feature: dynamic novel appbar (#998)
* update depd

* dynamic novel appbar

* fixed merge problems

* changed color from background to surface2

* added gitignore
2024-04-26 00:15:06 +07:00
Palloxin 484c2b7e33 Fix arabic alignment and language list order (#1024)
* Fix arabic alignment and language list order

- Left-to-Right for arabic language with u200e
- Fix alpabethic order

* Cleanup

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
2024-04-13 10:59:23 +05:30
nyagami b6fea9111c Fix: insert/update novel/chapter logic (#1019)
* fix: always create novelDir

* fix: download new chapters

* update novel totalPages if refreshNovelMetadata is off

* fix: updateChapter logic

* fix: insertId could be -1
2024-03-30 23:49:48 +07:00
K1ngfish3r 9ef8cab475 fix chapter.page when containing special character (#1015)
* fix page when containing special character

* page always defaults to 1

* AND page = ?
2024-03-26 22:29:19 +07:00
nyagami 7ea8ebacd1 Update plugin sanbox (#1014)
* add htmlparser2

* fix typo

* flatten plugins
2024-03-26 17:46:38 +07:00
nyagami e171dfebc1 Update lastest chapter logic (#1011)
update lastest chapter logic
2024-03-24 13:55:23 +07:00
nyagami 9f6ffc23bf Fix bugs & Reader improvement (#1009)
* fix: wrong chapter order

* fix: reader footer styles

* make swipe getures less sensitive

* fix: update only ongoing novels option

* synchonize update library

* add horizontal scrollbar

* update swipe gesture sensitive

* no swipe when image modal is visible

* add bionic reading

* fix: import untitled epub
2024-03-23 11:50:35 +07:00
nyagami 8906253527 Feat: protobuf support (#1003)
* feat: protobuf support

* fix: spead operator is slower

* fix: redundant JSON encoding

* add fetchProto to plugin sandbox
2024-03-21 16:37:07 +07:00
nyagami 817e4dc9d1 Cheaper epub parsing (#1007)
* cheaper epub parsing

* cleaner process

* no empty page list

* more epub detail

* fix nullable
2024-03-20 13:37:55 +07:00
225 changed files with 28620 additions and 18617 deletions
+55 -7
View File
@@ -9,49 +9,97 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone Repository (Latest)
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- name: Cache npm & node modules
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-cache-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-cache-npm-
- name: Install Dependencies
run: |
npm install
- name: Create .env
run: |
cat > .env << EOF
MYANIMELIST_CLIENT_ID=${{ vars.MYANIMELIST_CLIENT_ID }}
ANILIST_CLIENT_ID=${{ vars.ANILIST_CLIENT_ID }}
GIT_HASH=$(git rev-parse --short HEAD)
RELEASE_DATE=$(date --utc +'%d/%m/%y %I:%M %p %Z')
BUILD_TYPE=Github Action
EOF
- name: Cache Gradle Wrapper
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/wrapper
key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
- name: Cache Gradle Dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-caches-
- name: Cache build intermediates
uses: actions/cache@v4
with:
path: |
android/app/build/generated
android/app/build/intermediates
android/app/build/tmp
android/.gradle
key: ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }}
- name: Make Gradlew Executable
run: cd android && chmod +x ./gradlew
- name: Build Android Release
run: |
cd android && ./gradlew assembleRelease --no-daemon
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/LNReader.apk
- name: Set Env
run: |
set -x
echo "COMMIT_COUNT=$(git rev-list --count HEAD)" >> $GITHUB_ENV
echo "COMMIT_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Upload Artifact
uses: actions/upload-artifact@v1
env:
COMMIT_COUNT: ${{ env.COMMIT_COUNT }}
uses: actions/upload-artifact@v4
with:
name: LNReader-r${{ env.COMMIT_COUNT }}
path: android/app/build/outputs/apk/release/app-release.apk
path: android/app/build/outputs/apk/release/LNReader.apk
- name: Build Custom APK
env:
COMMIT_COUNT: ${{ env.COMMIT_COUNT }}
COMMIT_ID: ${{ env.COMMIT_ID }}
run: |
sed -i 's/LNReader/LNReader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})/g' android/app/src/main/res/values/strings.xml
cd android && ./gradlew assembleRelease --no-daemon -PcustomAppId=com.rajarsheechatterjee.LNReader.commit_${{ env.COMMIT_ID }}
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/LNReader-r${{ env.COMMIT_COUNT }}\(${{ env.COMMIT_ID }}\)-customID.apk
- name: Upload Custom APK
env:
COMMIT_COUNT: ${{ env.COMMIT_COUNT }}
COMMIT_ID: ${{ env.COMMIT_ID }}
uses: actions/upload-artifact@v4
with:
name: LNReader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})-customID
path: android/app/build/outputs/apk/release/LNReader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})-customID.apk
+3 -1
View File
@@ -74,4 +74,6 @@ web-build/
# @end expo-cli
.vscode/
src/sources/en/generators/
src/sources/en/generators/
*.lockb
.tool-versions
+3 -1
View File
@@ -1 +1,3 @@
**/node_modules
**/node_modules
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
};
+2 -8
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);
@@ -10,16 +11,12 @@ import LottieSplashScreen from 'react-native-lottie-splash-screen';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider as PaperProvider } from 'react-native-paper';
import * as Notifications from 'expo-notifications';
import BackgroundService from 'react-native-background-actions';
import { createTables } from '@database/db';
import AppErrorBoundary from '@components/AppErrorBoundary/AppErrorBoundary';
import { deserializePlugins } from '@plugins/pluginManager';
import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { BACKGROUND_ACTION } from '@services/constants';
Notifications.setNotificationHandler({
handleNotification: async () => {
@@ -32,10 +29,7 @@ Notifications.setNotificationHandler({
});
createTables();
deserializePlugins().then(() => LottieSplashScreen.hide());
if (!BackgroundService.isRunning()) {
MMKVStorage.delete(BACKGROUND_ACTION);
}
LottieSplashScreen.hide();
const App = () => {
return (
+6 -2
View File
@@ -80,7 +80,7 @@ android {
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.rajarsheechatterjee.LNReader"
defaultConfig {
applicationId 'com.rajarsheechatterjee.LNReader'
applicationId project.hasProperty('customAppId') ? project.getProperty('customAppId') : 'com.rajarsheechatterjee.LNReader'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Generated version code. Supports versions up to 1024.1024.2048
@@ -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.
@@ -113,6 +115,7 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'com.facebook.soloader:soloader:0.10.5+'
implementation 'androidx.core:core-ktx:1.9.0'
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
@@ -144,4 +147,5 @@ dependencies {
}
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle")
apply plugin: 'org.jetbrains.kotlin.android'; applyNativeModulesAppBuildGradle(project)
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">LNReader debug</string>
</resources>
+1 -3
View File
@@ -1,4 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rajarsheechatterjee.LNReader">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET" />
@@ -41,7 +41,6 @@
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
@@ -59,7 +58,6 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lnreader" />
<data android:scheme="com.rajarsheechatterjee.LNReader" />
</intent-filter>
</activity>
+282 -248
View File
@@ -1,334 +1,368 @@
html {
scroll-behavior: smooth;
overflow-x: hidden;
padding-top: var(--StatusBar-currentHeight);
word-wrap: break-word;
scroll-behavior: smooth;
overflow-x: hidden;
word-wrap: break-word;
}
::selection {
color: var(--theme-onSecondary);
background-color: var(--theme-secondary);
color: var(--theme-onSecondary);
background-color: var(--theme-secondary);
}
::-moz-selection {
color: var(--theme-onSecondary);
background-color: var(--theme-secondary);
color: var(--theme-onSecondary);
background-color: var(--theme-secondary);
}
body {
padding-left: var(--readerSettings-padding);
padding-right: var(--readerSettings-padding);
padding-bottom: 40px;
margin-left: 0;
margin-right: 0;
padding-bottom: 40px;
padding-top: var(--StatusBar-currentHeight);
font-size: var(--readerSettings-textSize);
background-color: var(--readerSettings-theme);
color: var(--readerSettings-textColor);
text-align: var(--readerSettings-textAlign);
line-height: var(--readerSettings-lineHeight);
font-family: var(--readerSettings-fontFamily);
font-size: var(--readerSettings-textSize);
background-color: var(--readerSettings-theme);
color: var(--readerSettings-textColor);
text-align: var(--readerSettings-textAlign);
line-height: var(--readerSettings-lineHeight);
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 {
margin-top: 20px;
margin-bottom: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
a {
color: var(--theme-primary);
color: var(--theme-primary);
}
img {
display: block;
width: auto;
height: auto;
max-width: 100%;
display: block;
width: auto;
height: auto;
max-width: 100%;
}
table {
background-color: var(--theme-onPrimary);
border-collapse: collapse;
color: var(--theme-primary);
}
th {
font-weight: bold;
}
td {
padding: 10px;
text-align: center;
}
table,
th,
td {
border: 1px solid var(--theme-outline);
}
.hidden {
visibility: hidden;
visibility: hidden;
}
.nextButton,
.infoText {
width: 100%;
border-radius: 50px;
border-width: 1;
color: var(--theme-onPrimary);
background-color: var(--theme-primary);
font-family: var(--readerSettings-fontFamily);
font-size: 16px;
border-width: 0;
user-select: none;
.next-button,
.info-text {
margin-left: var(--readerSettings-padding);
margin-right: var(--readerSettings-padding);
border-radius: 50px;
border-width: 1;
color: var(--theme-onPrimary);
background-color: var(--theme-primary);
font-family: var(--readerSettings-fontFamily);
font-size: 16px;
border-width: 0;
user-select: none;
}
.nextButton {
min-height: 40px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 0 16px;
.next-button {
min-height: 40px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 0 16px;
width: calc(100% - var(--readerSettings-padding) * 2);
}
.infoText {
background-color: transparent;
text-align: center;
border: none;
margin: 0px;
color: inherit;
padding-top: 16px;
padding-bottom: 16px;
}
.chapterCtn {
min-height: var(--chapterCtn-height);
margin-bottom: auto;
.info-text {
background-color: transparent;
text-align: center;
border: none;
margin: 0px;
color: inherit;
padding-top: 16px;
padding-bottom: 16px;
}
.d-none {
display: none;
display: none;
}
#ToolWrapper {
position: fixed;
top: 50%;
transform: translateY(-50%);
right: 5vw;
transition: 150ms;
position: fixed;
top: 50%;
transform: translateY(-50%);
right: 5vw;
}
#ToolWrapper.horizontal {
top: unset;
right: unset;
left: 50%;
transform: translateX(-50%);
bottom: 120px;
display: flex;
flex-direction: row-reverse;
align-items: center;
}
@media only screen and (min-width: 500px) {
#ToolWrapper.horizontal {
bottom: 80px;
}
}
#ToolWrapper.hidden {
opacity: 0;
right: 0;
opacity: 0;
right: 0;
}
#TTS-Controller {
display: block;
#ToolWrapper.hidden.horizontal {
opacity: 0;
bottom: 0;
right: unset;
}
#TTS-Controller button{
background: var(--theme-surface-0-9);
border: 0;
#ToolWrapper > #ScrollBar {
margin-top: 8px;
width: 2.4rem;
height: 50vh;
min-height: 200px;
border-radius: 1.2rem;
background-color: var(--theme-surface-0-9);
touch-action: none;
font-size: 14px;
user-select: none;
}
#TTS-Controller svg {
fill: var(--theme-onSurface);
#ScrollBar > .scrollbar-item {
display: flex;
justify-content: center;
align-items: center;
color: var(--theme-onSurface);
}
#ScrollBar {
margin-top: 8px;
width: 2.4rem;
height: 45vh;
min-height: 200px;
border-radius: 1.2rem;
background-color: var(--theme-surface-0-9);
touch-action: none;
font-size: 16px;
user-select: none;
#ScrollBar > .scrollbar-item.scrollbar-text {
height: 12.5%;
}
.scrollbar-item {
display: flex;
justify-content: center;
align-items: center;
color: var(--theme-onSurface);
#ScrollBar > .scrollbar-item#scrollbar-slider {
height: 75%;
align-items: unset;
}
.scrollbar-item.scrollbar-text {
height: 12.5%;
#ScrollBar #scrollbar-track {
width: 0.1rem;
height: 100%;
background-color: var(--theme-outline);
}
.scrollbar-item#scrollbar-slider {
height: 75%;
align-items: unset;
#ScrollBar #scrollbar-progress {
width: 100%;
height: 0;
background-color: var(--theme-primary);
}
#scrollbar-track {
width: 0.1rem;
height: 100%;
background-color: var(--theme-outline);
#ScrollBar #scrollbar-thumb-wrapper {
position: relative;
top: 100%;
height: 2rem;
width: 2rem;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-0.95rem, -1rem);
}
#scrollbar-progress {
width: 0.1rem;
height: 0;
background-color: var(--theme-primary);
#ScrollBar #scrollbar-thumb {
top: 100%;
height: 1rem;
width: 1rem;
border-radius: 100%;
background-color: var(--theme-primary);
}
#scrollbar-thumb-wrapper {
position: relative;
top: 100%;
height: 2rem;
width: 2rem;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-0.95rem, -1rem);
#ToolWrapper.horizontal > #ScrollBar {
display: flex;
width: 95.5vw;
height: 2.4rem;
min-height: unset;
align-items: center;
margin-top: unset;
}
#scrollbar-thumb {
top: 100%;
height: 1rem;
width: 1rem;
border-radius: 100%;
background-color: var(--theme-primary);
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item {
display: flex;
justify-content: center;
color: var(--theme-onSurface);
}
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item.scrollbar-text {
width: 12.5%;
}
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item#scrollbar-slider {
width: 75%;
align-items: center;
}
#ToolWrapper.horizontal > #ScrollBar #scrollbar-track {
height: 0.1rem;
width: 100%;
background-color: var(--theme-outline);
}
#ToolWrapper.horizontal > #ScrollBar #scrollbar-progress {
height: 100%;
width: 0;
background-color: var(--theme-primary);
}
#ToolWrapper.horizontal > #ScrollBar #scrollbar-thumb-wrapper {
position: relative;
left: 100%;
height: 2rem;
width: 2rem;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-0.95rem, -1rem);
}
#ToolWrapper.horizontal > #ScrollBar #scrollbar-thumb {
top: 100%;
height: 1rem;
width: 1rem;
border-radius: 100%;
background-color: var(--theme-primary);
}
#reader-footer-wrapper {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
}
#reader-footer {
display: flex;
flex: 1;
justify-content: space-between;
min-height: 2rem;
padding: 0.5rem 2rem 0 2rem;
background-color: var(--readerSettings-theme);
color: var(--readerSettings-textColor);
font-size: 1rem;
text-align: center;
user-select: none;
display: flex;
flex: 3;
padding: 0.2rem 2rem;
background-color: var(--readerSettings-theme);
color: var(--readerSettings-textColor);
font-size: 1rem;
text-align: center;
user-select: none;
}
.reader-footer-item {
display: flex;
flex: 1;
}
tts.highlight {
color: var(--theme-onSecondary);
background-color: var(--theme-secondary);
.reader-footer-item:first-child {
text-align: left;
}
.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;
.reader-footer-item:last-child {
text-align: right;
}
.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);
}
.highlight, .highlight *{
color: var(--theme-onSecondary) !important;
background-color: var(--theme-secondary);
}
#Image-Modal {
position: fixed;
left: 0;
top: 0;
user-select: none;
position: fixed;
left: 0;
top: 0;
user-select: none;
}
#Image-Modal.show {
display: flex;
height: 100%;
width: 100%;
background-color: var(--theme-surface-0-9);
align-items: center;
justify-content: center;
overflow: scroll;
display: flex;
height: 100%;
width: 100%;
background-color: var(--theme-surface-0-9);
align-items: center;
justify-content: center;
overflow: scroll;
}
#Image img {
display: block;
width: 100%;
}
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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+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 -586
View File
@@ -1,610 +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.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',
});
this.timeInterval = setInterval(() => {
this.time.innerText = new Date().toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}, 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,
);
new FontFace(
settings.fontFamily,
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
)
.load()
.then(function (loadedFont) {
document.fonts.add(loadedFont);
});
};
updateGeneralSettings = settings => {
this.showScrollPercentage = settings.showScrollPercentage;
this.showBatteryAndTime = settings.showBatteryAndTime;
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');
}
}
};
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 =
(e.changedTouches[0].clientY - 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) {
this.progress.style.height = 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 = () => {
this.sliderHeight = this.slider.clientHeight;
this.sliderOffsetY = this.slider.getBoundingClientRect().top;
};
onShow = () => {
this.reader.refresh();
this.refresh();
this.update();
};
}
class SwipeHandler {
constructor() {
this.initialX = null;
this.initialY = null;
}
touchStartHandler = e => {
this.initialX = e.changedTouches[0].screenX;
this.initialY = e.changedTouches[0].screenY;
};
touchEndHandler = e => {
let diffX = e.changedTouches[0].screenX - this.initialX;
let diffY = e.changedTouches[0].screenY - this.initialY;
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 10) {
e.preventDefault();
reader.post({ type: diffX < 0 ? 'next' : '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}
*/
class TextToSpeech {
constructor(reader) {
this.reader = reader;
(this.leaf = this.reader.chapter), (this.TTSWrapper = null); // swap these 2 elements
this.TTSEle = null;
this.reading = false;
this.$ = document.getElementById('TTS-Controller');
this.$.classList.add('d-none');
this.$.onclick = () => {
if (this.reading) {
this.pause();
} else {
this.resume();
}
};
}
start = () => {
this.$.innerHTML = `<button>${pauseIcon}</button>`;
this.$.classList.remove('d-none');
this.stop();
this.next();
};
startHere = () => {
this.$.innerHTML = `<button>${pauseIcon}</button>`;
this.$.classList.remove('d-none');
this.stop();
if (this.reader.selection.type === 'Range') {
if (this.leaf && this.TTSWrapper) {
this.TTSWrapper.replaceWith(this.leaf);
}
this.leaf = this.reader.selection.anchorNode;
this.makeLeafSpeakable();
this.speak();
} else {
this.next();
}
};
resume = () => {
this.$.innerHTML = `<button>${pauseIcon}</button>`;
this.next();
};
findLeaf() {
while (this.leaf.firstChild) {
this.leaf = this.leaf.firstChild;
}
}
findNextLeaf() {
if (this.reader.chapter.isSameNode(this.leaf)) {
this.findLeaf();
} else if (this.leaf.nextSibling) {
this.leaf = this.leaf.nextSibling;
this.findLeaf();
} else {
this.leaf = this.leaf.parentNode;
if (this.reader.chapter.isSameNode(this.leaf)) {
return;
}
this.findNextLeaf();
}
}
readable() {
return (
this.leaf.nodeName === '#text' && /[^\s\n,.?!;":“”]/.test(this.leaf.data)
);
}
findNextTextNode() {
if (this.leaf && this.TTSWrapper) {
this.TTSWrapper.replaceWith(this.leaf);
this.TTSWrapper = null;
}
do {
this.findNextLeaf();
} while (!this.readable() && !this.reader.chapter.isSameNode(this.leaf));
if (this.reader.chapter.isSameNode(this.leaf)) {
return;
}
this.makeLeafSpeakable();
}
makeLeafSpeakable() {
this.TTSWrapper = document.createElement('tts-wrapper');
this.TTSWrapper.innerHTML = this.leaf.data.replace(
/([^\n,.?!;":“”]+)/g,
matched => {
if (matched.trim().length) {
return `<tts>${matched}</tts>`;
}
return matched;
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',
},
);
this.TTSEle = this.TTSWrapper.firstElementChild;
this.leaf.replaceWith(this.TTSWrapper);
}
() =>
reader.generalSettings.val.pageReader ? pageReader.totalPages.val : 100,
),
);
};
next = () => {
try {
if (this.TTSEle) {
this.TTSEle.classList.remove('highlight');
}
if (this.reading) {
if (this.TTSEle && this.TTSEle.nextElementSibling) {
this.TTSEle = this.TTSEle.nextElementSibling;
} else {
this.findNextTextNode();
const ToolWrapper = () => {
const horizontal = van.derive(
() => !reader.generalSettings.val.verticalSeekbar,
);
return div(
{
id: 'ToolWrapper',
class: () =>
`${reader.hidden.val ? 'hidden' : ''} ${
horizontal.val ? 'horizontal' : ''
}`,
},
Scrollbar(),
);
};
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 = '';
}
} else {
if (!this.TTSEle) {
this.findNextTextNode();
}
}
this.speak();
} catch (e) {
alert(e);
}
};
},
},
img({
id: 'Image-Modal-img',
src: src,
alt: () => (src.val ? `Cant not render image from ${src.val}` : ''),
}),
);
};
stop = () => {
this.$.classList.add('d-none');
this.reading = false;
if (this.leaf && this.TTSWrapper) {
this.TTSWrapper.replaceWith(this.leaf);
this.TTSWrapper = null;
this.TTSEle = null;
this.leaf = this.reader.chapter;
}
this.reader.post({ type: 'stop-speak' });
};
pause = () => {
this.$.innerHTML = `<button>${resumeIcon}</button>`;
this.reading = false;
this.reader.post({ type: 'stop-speak' });
};
started = () => this.TTSWrapper !== null;
speak = () => {
this.reading = true;
if (this.reader.chapter.isSameNode(this.leaf)) {
return;
}
this.TTSEle.classList.add('highlight');
this.reader.post({ type: 'speak', data: this.TTSEle?.innerText.trim() });
};
}
/**
* @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: () => {
tts.start();
this.closeMenu();
},
}),
START_HERE: this.renderItem({
name: 'Start Here',
icon: selectVolumeIcon,
action: () => {
tts.startHere();
this.closeMenu();
},
}),
COPY: this.renderItem({
name: 'Copy',
icon: copyIcon,
action: () => {
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());
@@ -0,0 +1 @@
var textVide = function(L){'use strict'; const f = t=>t == null || t === '',h = (t,n)=>Object.keys(t).reduce((e,s)=>(n(e[s]) && delete e[s],e),t),I = (t,n)=>({...n,...h(t,f)}),_ = ['<b>','</b>'],y = t=>I(t,{sep:_,fixationPoint:1,ignoreHtmlTag:!0,ignoreHtmlEntity:!0}),u = [[0,4,12,17,24,29,35,42,48],[1,2,7,10,13,14,19,22,25,28,31,34,37,40,43,46,49],[1,2,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49],[0,2,4,5,6,8,9,11,14,15,17,18,20,0,21,23,24,26,27,29,30,32,33,35,36,38,39,41,42,44,45,47,48],[0,2,3,5,6,7,8,10,11,12,14,15,17,19,20,21,23,24,25,26,28,29,30,32,33,34,35,37,38,39,41,42,43,44,46,47,48]],A = (t,n)=>{const {length:i} = t,e = u[n - 1] ?? u[0],s = e.findIndex(c=>i <= c); let o = i - s; return s === -1 && (o = i - e.length),Math.max(o,0);},R = (t,n)=>typeof n === 'string' ? `${n}${t}${n}` : `${n[0]}${t}${n[1]}`,m = t=>Array.from(t).map(n=>{const i = n.index,[e] = n,{length:s} = e; return [i,i + s - 1];}),H = /(<!--[\s\S]*?-->)|(<[^>]*>)/g,N = t=>{const n = t.matchAll(H),e = m(n).reverse(); return s=>{const o = s.index,c = e.find(([r])=>o > r); if (!c) {return !1;} const [,a] = c; return o < a;};},O = /(&[\w#]+;)/g,M = t=>{const n = t.matchAll(O),e = m(n).reverse(); return s=>{const o = s.index,c = e.find(([r])=>o > r); if (!c) {return !1;} const [,a] = c; return o < a;};},x = /(\p{L}|\p{Nd})*\p{L}(\p{L}|\p{Nd})*/gu,F = (t,n = {})=>{if (!(t != null && t.length)) {return '';} const {fixationPoint:i,sep:e,ignoreHtmlTag:s,ignoreHtmlEntity:o} = y(n),c = Array.from(t.matchAll(x)); let a = '',l = 0,r; s && (r = N(t)); let d; o && (d = M(t)); for (const g of c){if ((r == null ? void 0 : r(g)) || (d == null ? void 0 : d(g))) {continue;} const [D] = g,T = g.index,E = T + A(D,i),U = t.slice(l,T); a += U,T !== E && (a += R(t.slice(T,E),e)),l = E;} const G = t.slice(l); return a + G;}; return L.textVide = F,Object.defineProperty(L,Symbol.toStringTag,{value:'Module'}),L;}({});
-70
View File
@@ -1,70 +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: HTMLElement;
TTSWrapper: HTMLElement;
TTSEle: HTMLElement;
reading: boolean;
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;}
@@ -0,0 +1,3 @@
package com.rajarsheechatterjee.EpubUtil
class ChapterEntry (val name: String, val href: String)
@@ -0,0 +1,204 @@
package com.rajarsheechatterjee.EpubUtil
import android.util.Xml
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.FileInputStream
class EpubUtil(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "EpubUtil"
}
private fun initParse(file: File): XmlPullParser {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(FileInputStream(file), null)
parser.nextTag()
return parser
}
private fun readText(parser: XmlPullParser): String {
var result = ""
if (parser.next() == XmlPullParser.TEXT) {
result = parser.text
parser.nextTag()
}
return result
}
private fun cleanUrl(url: String): String {
return url.replaceFirst("#[^.]+?$".toRegex(), "")
}
@ReactMethod
fun parseNovelAndChapters(epubDirPath: String, promise: Promise) {
try {
val containerFile = File(epubDirPath, "META-INF/container.xml")
val contentMetaFile = File(epubDirPath, getContentMetaFilePath(containerFile))
val contentDir = contentMetaFile.parent
val novel = contentDir?.let { getNovelMetadata(contentMetaFile, it) }
promise.resolve(novel)
} catch (e: Exception) {
promise.reject(e)
}
}
private fun getContentMetaFilePath(file: File): String {
val parser = initParse(file)
while (parser.next() != XmlPullParser.END_TAG) {
val tag = parser.name
if (tag != null && tag == "rootfile") {
return parser.getAttributeValue(null, "full-path")
}
}
return "OEBPS/content.opf" // default
}
private fun mergeChapters(
entryList: List<ChapterEntry>,
fileList: List<String>,
contentDir: String
): ReadableArray {
val foundIndexes = entryList.mapIndexed { index, entry ->
val foundIndex = fileList.indexOf(entry.href)
if (foundIndex < index) -1
else foundIndex
}
val linkedIndex = foundIndexes.mapIndexed { arrayIndex, foundIndex ->
if (foundIndex == -1) Pair(-1, -1)
else {
var nextIndex = -1
for (j in arrayIndex + 1 until foundIndexes.size) {
if (foundIndexes[j] != -1) {
nextIndex = foundIndexes[j]
break
}
}
if (nextIndex == -1) nextIndex = fileList.size
Pair(foundIndex, nextIndex)
}
}
val chapters: WritableArray = WritableNativeArray()
linkedIndex.forEachIndexed { arrayIndex, (firstIndex, lastIndex) ->
val chapter = WritableNativeMap()
val entryPath = "${contentDir}/${entryList[arrayIndex].href}"
chapter.putString("name", entryList[arrayIndex].name)
if (firstIndex == -1 || firstIndex == lastIndex) {
chapter.putString("path", entryPath)
} else {
val mergedChapterPath = "${entryPath}-copy-$arrayIndex.html"
chapter.putString("path", mergedChapterPath)
val entryFile = File(entryPath)
val mergedFile = File(mergedChapterPath)
mergedFile.createNewFile()
mergedFile.appendBytes(entryFile.readBytes())
for (i in firstIndex + 1 until lastIndex) {
val file = File("${contentDir}/${fileList[i]}")
mergedFile.appendBytes(file.readBytes())
}
}
chapters.pushMap(chapter)
}
return chapters
}
private fun getNovelMetadata(file: File, contentDir: String): ReadableMap {
val novel: WritableMap = WritableNativeMap()
val parser = initParse(file)
val refMap = HashMap<String, String>()
val entryList = mutableListOf<ChapterEntry>()
val fileList = mutableListOf<String>()
val tocFile = File(contentDir, "toc.ncx")
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) {
val tag = tocParser.name
if (tag == "navMap") break
if (tag != null) {
if (tag == "text") {
label = readText(tocParser)
} else if (tag == "content") {
val href = cleanUrl(tocParser.getAttributeValue(null, "src"))
if (label.isNotBlank() && File("${contentDir}/$href").exists()) {
entryList.add(ChapterEntry(name = label, href = href))
}
label = ""
}
}
}
} else {
throw Exception("Table of content (toc.ncx) does not exist!")
}
var cover: String? = null
while (parser.next() != XmlPullParser.END_DOCUMENT) {
val tag = parser.name
if (tag != null) {
when (tag) {
"item" -> {
val id = parser.getAttributeValue(null, "id")
val href = parser.getAttributeValue(null, "href")
if (id != null) {
refMap[id] = href
}
}
"itemref" -> {
val idRef = parser.getAttributeValue(null, "idref")
val href = refMap[idRef]
if (href != null && File("$contentDir/$href").exists()) {
fileList.add(href)
}
}
"title" -> novel.putString("name", readText(parser))
"creator" -> novel.putString("author", readText(parser))
"contributor" -> novel.putString("artist", readText(parser))
"description" -> novel.putString("summary", readText(parser))
"meta" -> {
val metaName = parser.getAttributeValue(null, "name")
if (metaName != null && metaName == "cover") {
cover = parser.getAttributeValue(null, "content")
}
}
}
parser.next()
}
}
val chapters = mergeChapters(entryList, fileList, contentDir)
novel.putArray("chapters", chapters)
if (cover != null) {
val coverPath = contentDir + "/" + refMap[cover]
novel.putString("cover", coverPath)
} else {
// try scanning Images dir if exists
val imageDir = File("$contentDir/Images")
if (imageDir.exists() && imageDir.isDirectory) {
imageDir.listFiles()?.forEach { img ->
if (img.isFile && img.name.lowercase().contains("cover|illus".toRegex())) {
cover = img.path
}
}
}
if (cover != null) {
novel.putString("cover", cover)
}
}
return novel
}
}
@@ -0,0 +1,17 @@
package com.rajarsheechatterjee.EpubUtil
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class EpubUtilPackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext): MutableList<NativeModule> =
listOf(EpubUtil(context)).toMutableList()
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()
}
@@ -0,0 +1,344 @@
package com.rajarsheechatterjee.FileManager
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.network.CookieJarContainer
import com.facebook.react.modules.network.ForwardingCookieHandler
import com.facebook.react.modules.network.OkHttpClientProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.JavaNetCookieJar
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.PushbackInputStream
import java.util.zip.GZIPInputStream
class FileManager(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "FileManager"
}
private val BUFFER_SIZE = 4096
private val okHttpClient = OkHttpClientProvider.createClient()
private var _promise: Promise? = null
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val activityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult(
activity: Activity?,
requestCode: Int,
resultCode: Int,
intent: Intent?
) {
if (requestCode == FOLDER_PICKER_REQUEST) {
when (resultCode) {
Activity.RESULT_CANCELED -> resolveUri()
Activity.RESULT_OK -> {
if (intent != null) {
intent.data?.let { uri ->
val flags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
resolveUri(uri)
}
}
}
}
}
}
}
init {
context.addActivityEventListener(activityEventListener)
val cookieContainer = okHttpClient.cookieJar as CookieJarContainer
val cookieHandler = ForwardingCookieHandler(reactApplicationContext)
cookieContainer.setCookieJar(JavaNetCookieJar(cookieHandler))
}
private fun getFileUri(filepath: String): Uri {
var uri = Uri.parse(filepath)
if (uri.scheme == null) {
// No prefix, assuming that provided path is absolute path to file
val file = File(filepath)
if (file.isDirectory) {
throw Exception("Invalid file, folder found!")
}
uri = Uri.parse("file://$filepath")
}
return uri
}
private fun getInputStream(filepath: String): InputStream {
val uri = getFileUri(filepath)
return reactApplicationContext.contentResolver.openInputStream(uri)
?: throw Exception("ENOENT: could not open an input stream for '$filepath'")
}
private val writeAccessByAPILevel: String
get() = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) "w" else "rwt"
private fun getOutputStream(filepath: String): OutputStream {
val uri = getFileUri(filepath)
return reactApplicationContext.contentResolver.openOutputStream(uri, writeAccessByAPILevel)
?: throw Exception("ENOENT: could not open an output stream for '$filepath'")
}
@ReactMethod
fun writeFile(path: String, content: String, promise: Promise) {
try {
val fw = FileWriter(path)
fw.write(content)
fw.close()
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
fun readFile(path: String): String {
return File(path).bufferedReader().readText()
}
@ReactMethod
fun copyFile(filepath: String, destPath: String, promise: Promise) {
try {
copyFileContent(filepath, destPath, null, promise)
} catch (e: Exception) {
promise.reject(e)
}
}
@ReactMethod
fun moveFile(filepath: String, destPath: String, promise: Promise) {
try {
val inFile = File(filepath)
copyFileContent(filepath, destPath, { inFile.delete() }, promise)
} catch (e: Exception) {
promise.reject(e)
}
}
private fun copyFileContent(
filepath: String,
destPath: String,
onDone: (() -> Unit)? = null,
promise: Promise? = null
) {
coroutineScope.launch(Dispatchers.IO) {
val inputStream = getInputStream(filepath)
val outputStream = getOutputStream(destPath)
val buffer = ByteArray(1024)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
outputStream.write(buffer, 0, length)
Thread.yield()
}
inputStream.close()
outputStream.close()
if (onDone != null) {
onDone()
}
promise?.resolve(null)
}
}
@ReactMethod
fun exists(filepath: String, promise: Promise) {
try {
val file = File(filepath)
promise.resolve(file.exists())
} catch (e: Exception) {
promise.reject(e)
}
}
@ReactMethod
fun mkdir(filepath: String, promise: Promise) {
try {
val file = File(filepath)
if (!file.exists()) {
val created = file.mkdirs()
if (!created) throw Exception("Directory could not be created")
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
private fun deleteRecursive(fileOrDirectory: File) {
if (fileOrDirectory.isDirectory) {
for (child in fileOrDirectory.listFiles()!!) {
deleteRecursive(child)
}
}
fileOrDirectory.delete()
}
@ReactMethod
fun unlink(filepath: String, promise: Promise) {
try {
val file = File(filepath)
if (!file.exists()) throw Exception("File does not exist")
deleteRecursive(file)
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
@ReactMethod
fun readDir(directory: String, promise: Promise) {
try {
val file = File(directory)
if (!file.exists()) throw Exception("Folder does not exist")
val files = file.listFiles()
val fileMaps: WritableArray = WritableNativeArray()
for (childFile in files!!) {
val fileMap: WritableMap = WritableNativeMap()
fileMap.putString("name", childFile.name)
fileMap.putString("path", childFile.absolutePath)
fileMap.putBoolean("isDirectory", childFile.isDirectory)
fileMaps.pushMap(fileMap)
}
promise.resolve(fileMaps)
} catch (e: Exception) {
promise.reject(e)
}
}
override fun getConstants(): Map<String, Any> {
val constants: MutableMap<String, Any> = HashMap()
val externalDirectory = this.reactApplicationContext.getExternalFilesDir(null)
if (externalDirectory != null) {
constants["ExternalDirectoryPath"] = externalDirectory.absolutePath
}
val externalCachesDirectory = this.reactApplicationContext.externalCacheDir
if (externalCachesDirectory != null) {
constants["ExternalCachesDirectoryPath"] = externalCachesDirectory.absolutePath
}
return constants
}
private fun resolveUri(uri: Uri? = null) {
_promise?.let { promise ->
try {
if (uri == null) promise.resolve(null)
else {
val docId = DocumentsContract.getTreeDocumentId(uri)
val split =
docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
promise.resolve(
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
)
}
} catch (e: Exception) {
promise.reject(e)
} finally {
_promise = null
}
}
}
@ReactMethod
fun pickFolder(promise: Promise) {
if (_promise != null) promise.reject(Exception("Can not perform action"))
_promise = promise
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
currentActivity?.startActivityForResult(intent, FOLDER_PICKER_REQUEST)
}
private fun decompressStream(input: InputStream?): InputStream {
val pb = PushbackInputStream(input, 2)
val signature = ByteArray(2)
val len = pb.read(signature)
pb.unread(signature, 0, len)
return if (signature[0] == 0x1f.toByte() && signature[1] == 0x8b.toByte())
GZIPInputStream(pb) else pb
}
@ReactMethod
fun downloadFile(
url: String,
destPath: String,
method: String,
headers: ReadableMap,
body: String?,
promise: Promise
) {
coroutineScope.launch {
try {
val headersBuilder = Headers.Builder()
headers.entryIterator.forEach { entry ->
headersBuilder.add(entry.key, entry.value.toString())
}
val requestBuilder = Request.Builder()
.url(url)
.headers(headersBuilder.build())
if (method.lowercase() == "get") {
requestBuilder.get()
} else if (body != null) {
requestBuilder.post(body.toRequestBody())
}
okHttpClient.newCall(requestBuilder.build())
.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
promise.reject(e)
}
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful || response.body == null) {
promise.reject(Exception("Failed to download load: ${response.code}"))
return
}
try {
val inputStream = decompressStream(response.body!!.byteStream())
FileOutputStream(destPath).use { fos ->
inputStream.copyTo(fos, BUFFER_SIZE)
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}
})
} catch (e: Exception) {
promise.reject(e)
}
}
}
companion object {
const val FOLDER_PICKER_REQUEST = 1
}
}
@@ -0,0 +1,17 @@
package com.rajarsheechatterjee.FileManager
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class FileManagerPackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext): MutableList<NativeModule> =
listOf(FileManager(context)).toMutableList()
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()
}
@@ -1,87 +0,0 @@
package com.rajarsheechatterjee.LNReader;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import expo.modules.ReactActivityDelegateWrapper;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListener;
import org.devio.rn.splashscreen.SplashScreen;
public class MainActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams();
layoutParams.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(layoutParams);
}
Window w = getWindow();
w.setStatusBarColor(Color.TRANSPARENT);
w.setNavigationBarColor(Color.TRANSPARENT);
w.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
super.onCreate(null);
SplashScreen.show(this,R.style.SplashScreenTheme, R.id.lottie);
SplashScreen.setAnimationFinished(true);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (VolumeButtonListener.isActive()) {
int action = event.getAction();
int keyCode = event.getKeyCode();
switch (keyCode) {
case KeyEvent.KEYCODE_VOLUME_UP:
if (action == KeyEvent.ACTION_DOWN) {
// volup
VolumeButtonListener.up();
if (!VolumeButtonListener.prevent)
return super.dispatchKeyEvent(event);
}
return true;
case KeyEvent.KEYCODE_VOLUME_DOWN:
if (action == KeyEvent.ACTION_DOWN) {
// voldown
VolumeButtonListener.down();
if (!VolumeButtonListener.prevent)
return super.dispatchKeyEvent(event);
}
return true;
default:
return super.dispatchKeyEvent(event);
}
}
return super.dispatchKeyEvent(event);
}
/**
* Returns the name of the main component registered from JavaScript.
* This is used to schedule rendering of the component.
*/
@Override
protected String getMainComponentName() {
return "main";
}
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, new DefaultReactActivityDelegate(
this,
getMainComponentName(),
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
DefaultNewArchitectureEntryPoint.getFabricEnabled()
));
}
}
@@ -0,0 +1,80 @@
package com.rajarsheechatterjee.LNReader
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListener
import expo.modules.ReactActivityDelegateWrapper
import org.devio.rn.splashscreen.SplashScreen
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val layoutParams = WindowManager.LayoutParams()
layoutParams.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes = layoutParams
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
super.onCreate(null)
SplashScreen.show(this, R.style.SplashScreenTheme, R.id.lottie)
SplashScreen.setAnimationFinished(true)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (VolumeButtonListener.isActive) {
val action = event.action
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
if (action == KeyEvent.ACTION_DOWN) {
// volup
VolumeButtonListener.up()
if (!VolumeButtonListener.prevent) return super.dispatchKeyEvent(event)
}
true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (action == KeyEvent.ACTION_DOWN) {
// voldown
VolumeButtonListener.down()
if (!VolumeButtonListener.prevent) return super.dispatchKeyEvent(event)
}
true
}
else -> super.dispatchKeyEvent(event)
}
}
return super.dispatchKeyEvent(event)
}
/**
* Returns the name of the main component registered from JavaScript.
* This is used to schedule rendering of the component.
*/
override fun getMainComponentName(): String {
return "main"
}
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(
this,
mainComponentName, // If you opted-in for the New Architecture, we enable the Fabric Renderer.
fabricEnabled
)
)
}
}
@@ -1,80 +0,0 @@
package com.rajarsheechatterjee.LNReader;
import android.app.Application;
import android.content.res.Configuration;
import androidx.annotation.NonNull;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactNativeHost;
import com.facebook.soloader.SoLoader;
import com.rajarsheechatterjee.NavigationBarColor.NavigationBarColorPackage;
import com.rajarsheechatterjee.TextFile.TextFilePackage;
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListenerPackage;
import com.rajarsheechatterjee.ZipArchive.ZipArchivePackage;
import expo.modules.ApplicationLifecycleDispatcher;
import expo.modules.ReactNativeHostWrapper;
import java.util.List;
public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost =
new ReactNativeHostWrapper(this, new DefaultReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new NavigationBarColorPackage());
packages.add(new VolumeButtonListenerPackage());
packages.add(new ZipArchivePackage());
packages.add(new TextFilePackage());
return packages;
}
@Override
protected String getJSMainModuleName() {
return "index";
}
@Override
protected boolean isNewArchEnabled() {
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
}
@Override
protected Boolean isHermesEnabled() {
return BuildConfig.IS_HERMES_ENABLED;
}
});
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
DefaultNewArchitectureEntryPoint.load();
}
ApplicationLifecycleDispatcher.onApplicationCreate(this);
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig);
}
}
@@ -0,0 +1,64 @@
package com.rajarsheechatterjee.LNReader
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.rajarsheechatterjee.EpubUtil.EpubUtilPackage
import com.rajarsheechatterjee.FileManager.FileManagerPackage
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListenerPackage
import com.rajarsheechatterjee.ZipArchive.ZipArchivePackage
import expo.modules.ApplicationLifecycleDispatcher.onApplicationCreate
import expo.modules.ApplicationLifecycleDispatcher.onConfigurationChanged
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
private val mReactNativeHost: ReactNativeHost =
ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
override fun getUseDeveloperSupport(): Boolean {
return BuildConfig.DEBUG
}
override fun getPackages(): List<ReactPackage> {
val packages: MutableList<ReactPackage> = PackageList(this).packages
packages.add(VolumeButtonListenerPackage())
packages.add(ZipArchivePackage())
packages.add(FileManagerPackage())
packages.add(EpubUtilPackage())
return packages
}
override fun getJSMainModuleName(): String {
return "index"
}
override val isNewArchEnabled: Boolean
get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean
get() = BuildConfig.IS_HERMES_ENABLED
})
override fun getReactNativeHost(): ReactNativeHost {
return mReactNativeHost
}
override fun onCreate() {
super.onCreate()
SoLoader.init(this, /* native exopackage */false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
onConfigurationChanged(this, newConfig)
}
}
@@ -1,167 +0,0 @@
package com.rajarsheechatterjee.NavigationBarColor;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.graphics.Color;
import android.os.Build;
import android.app.Activity;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import java.util.HashMap;
import java.util.Map;
import com.facebook.react.uimanager.IllegalViewOperationException;
import static com.facebook.react.bridge.UiThreadUtil.runOnUiThread;
public class NavigationBarColorModule extends ReactContextBaseJavaModule {
public static final String REACT_CLASS = "NavigationBarColor";
private static final String ERROR_NO_ACTIVITY = "E_NO_ACTIVITY";
private static final String ERROR_NO_ACTIVITY_MESSAGE = "Tried to change the navigation bar while not attached to an Activity";
private static final String ERROR_API_LEVEL = "API_LEVEl";
private static final String ERROR_API_LEVEL_MESSAGE = "Only Android Oreo and above is supported";
private static ReactApplicationContext reactContext = null;
private static final int UI_FLAG_HIDE_NAV_BAR = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // hide nav bar
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
public NavigationBarColorModule(ReactApplicationContext context) {
super(context);
reactContext = context;
}
public void setNavigationBarTheme(Activity activity, Boolean light) {
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Window window = activity.getWindow();
int flags = window.getDecorView().getSystemUiVisibility();
if (light) {
flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
} else {
flags &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
}
window.getDecorView().setSystemUiVisibility(flags);
}
}
@Override
public String getName() {
return REACT_CLASS;
}
@Override
public Map<String, Object> getConstants() {
final Map<String, Object> constants = new HashMap<>();
constants.put("EXAMPLE_CONSTANT", "example");
return constants;
}
@ReactMethod
public void changeNavigationBarColor(final String color, final Boolean light, final Boolean animated, final Promise promise) {
final WritableMap map = Arguments.createMap();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (getCurrentActivity() != null) {
try {
final Window window = getCurrentActivity().getWindow();
runOnUiThread(new Runnable() {
@Override
public void run() {
if (color.equals("transparent") || color.equals("translucent")) {
window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
if (color.equals("transparent")) {
window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
} else {
window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
}
setNavigationBarTheme(getCurrentActivity(), light);
map.putBoolean("success", true);
promise.resolve(map);
return;
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
}
if (animated) {
Integer colorFrom = window.getNavigationBarColor();
Integer colorTo = Color.parseColor(String.valueOf(color));
//window.setNavigationBarColor(colorTo);
ValueAnimator colorAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), colorFrom, colorTo);
colorAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animator) {
window.setNavigationBarColor((Integer) animator.getAnimatedValue());
}
});
colorAnimation.start();
} else {
window.setNavigationBarColor(Color.parseColor(String.valueOf(color)));
}
setNavigationBarTheme(getCurrentActivity(), light);
WritableMap map = Arguments.createMap();
map.putBoolean("success", true);
promise.resolve(map);
}
});
} catch (IllegalViewOperationException e) {
map.putBoolean("success", false);
promise.reject("error", e);
}
} else {
promise.reject(ERROR_NO_ACTIVITY, new Throwable(ERROR_NO_ACTIVITY_MESSAGE));
}
} else {
promise.reject(ERROR_API_LEVEL, new Throwable(ERROR_API_LEVEL_MESSAGE));
}
}
@ReactMethod
public void hideNavigationBar(Promise promise) {
try {
runOnUiThread(new Runnable() {
@Override
public void run() {
if (getCurrentActivity() != null) {
View decorView = getCurrentActivity().getWindow().getDecorView();
decorView.setSystemUiVisibility(UI_FLAG_HIDE_NAV_BAR);
}
}
});
} catch (IllegalViewOperationException e) {
WritableMap map = Arguments.createMap();
map.putBoolean("success", false);
promise.reject("error", e);
}
}
@ReactMethod
public void showNavigationBar(Promise promise) {
try {
runOnUiThread(new Runnable() {
@Override
public void run() {
if (getCurrentActivity() != null) {
Window w = getCurrentActivity().getWindow();
w.setStatusBarColor(Color.TRANSPARENT);
w.setNavigationBarColor(Color.TRANSPARENT);
View decorView = w.getDecorView();
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
}
}
});
} catch (IllegalViewOperationException e) {
WritableMap map = Arguments.createMap();
map.putBoolean("success", false);
promise.reject("error", e);
}
}
}
@@ -1,25 +0,0 @@
package com.rajarsheechatterjee.NavigationBarColor;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class NavigationBarColorPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new NavigationBarColorModule(reactContext));
return modules;
}
}
@@ -1,53 +0,0 @@
package com.rajarsheechatterjee.TextFile;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
public class TextFile extends ReactContextBaseJavaModule {
TextFile(ReactApplicationContext context) {
super(context);
}
@NonNull
@Override
public String getName() {
return "TextFile";
}
@ReactMethod
public void writeFile(String path, String content, Promise promise) {
try {
FileWriter fw = new FileWriter(path);
fw.write(content);
fw.close();
promise.resolve(null);
} catch (Exception e) {
promise.reject(e);
}
}
@ReactMethod void readFile(String path, Promise promise) {
try {
StringBuilder sb = new StringBuilder();
BufferedReader br = new BufferedReader(new FileReader(path));
String line;
while ((line = br.readLine()) != null) sb.append(line).append('\n');
promise.resolve(sb.toString());
} catch (Exception e) {
promise.reject(e);
}
}
}
@@ -1,32 +0,0 @@
package com.rajarsheechatterjee.TextFile;
import androidx.annotation.NonNull;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class TextFilePackage implements ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactApplicationContext) {
List<NativeModule> modules = new ArrayList<>();
try {
modules.add(new TextFile(reactApplicationContext));
} catch (Exception e) {
throw new RuntimeException(e);
}
return modules;
}
@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactApplicationContext) {
return Collections.emptyList();
}
}
@@ -1,93 +0,0 @@
package com.rajarsheechatterjee.VolumeButtonListener;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
public class VolumeButtonListener extends ReactContextBaseJavaModule {
static ReactApplicationContext appContext;
public VolumeButtonListener(ReactApplicationContext context) {
super(context);
appContext = context;
}
private static boolean active = false;
public static boolean isActive() {
return active;
}
public static boolean prevent = false;
public static void sendEvent(boolean up) {
if (active) {
WritableMap args = Arguments.createMap();
if (up)
appContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("VolumeUp", args);
else
appContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("VolumeDown", args);
}
}
public static void down() {
sendEvent(false);
}
public static void up() {
sendEvent(true);
}
@ReactMethod
public void connect() {
active = true;
}
@ReactMethod
void disconnect() {
active = false;
}
@ReactMethod
public void pause() {
active = false;
}
@ReactMethod
public void unpause() {
active = true;
}
@ReactMethod
public void preventDefault() {
prevent = true;
}
@ReactMethod
public void noPreventDefault() {
prevent = false;
}
@NonNull
@Override
public String getName() {
return "VolumeButtonListener";
}
@ReactMethod
public void addListener(String eventName) {
// Keep: Required for RN built in Event Emitter Calls.
}
@ReactMethod
public void removeListeners(Integer count) {
// Keep: Required for RN built in Event Emitter Calls.
}
}
@@ -0,0 +1,86 @@
package com.rajarsheechatterjee.VolumeButtonListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
class VolumeButtonListener(appContext: ReactApplicationContext) :
ReactContextBaseJavaModule(appContext) {
init {
VolumeButtonListener.appContext = appContext
}
@ReactMethod
fun connect() {
isActive = true
}
@ReactMethod
fun disconnect() {
isActive = false
}
@ReactMethod
fun pause() {
isActive = false
}
@ReactMethod
fun unpause() {
isActive = true
}
@ReactMethod
fun preventDefault() {
prevent = true
}
@ReactMethod
fun noPreventDefault() {
prevent = false
}
override fun getName(): String {
return "VolumeButtonListener"
}
@ReactMethod
fun addListener(eventName: String?) {
// Keep: Required for RN built in Event Emitter Calls.
}
@ReactMethod
fun removeListeners(count: Int?) {
// Keep: Required for RN built in Event Emitter Calls.
}
companion object {
lateinit var appContext: ReactApplicationContext
var isActive = false
private set
var prevent = false
fun sendEvent(up: Boolean) {
if (isActive) {
val args = Arguments.createMap()
if (up) appContext.getJSModule(
DeviceEventManagerModule.RCTDeviceEventEmitter::class.java
).emit(
"VolumeUp",
args
) else appContext.getJSModule(
DeviceEventManagerModule.RCTDeviceEventEmitter::class.java
).emit("VolumeDown", args)
}
}
fun down() {
sendEvent(false)
}
fun up() {
sendEvent(true)
}
}
}
@@ -1,30 +0,0 @@
package com.rajarsheechatterjee.VolumeButtonListener;
import androidx.annotation.NonNull;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class VolumeButtonListenerPackage implements ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<NativeModule>();
modules.add(new VolumeButtonListener(reactContext));
return modules;
}
@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
@@ -0,0 +1,16 @@
package com.rajarsheechatterjee.VolumeButtonListener
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class VolumeButtonListenerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> = listOf(VolumeButtonListener(reactContext)).toMutableList()
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()
}
@@ -1,169 +0,0 @@
package com.rajarsheechatterjee.ZipArchive;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class ZipArchive extends ReactContextBaseJavaModule {
ZipArchive(ReactApplicationContext context) throws Exception {
super(context);
}
@NonNull
@Override
public String getName() {
return "ZipArchive";
}
private String escapeFilePath(String filePath){
return filePath.replaceAll(":", "\uA789");
}
private void unzipProcess(ZipInputStream zis, String distDirPath) throws Exception {
ZipEntry zipEntry;
int len;
byte[] buffer = new byte[4096];
while ((zipEntry = zis.getNextEntry()) != null) {
if(zipEntry.getName().endsWith("/")) continue;
String escapedFilePath = this.escapeFilePath(zipEntry.getName());
File newFile = new File(distDirPath, escapedFilePath);
newFile.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(newFile);
while ((len = zis.read(buffer)) > 0) fos.write(buffer, 0, len);
fos.close();
}
zis.closeEntry();
}
@ReactMethod
public void unzip(String sourceFilePath, String distDirPath, Promise promise) {
new Thread(new Runnable() {
@Override
public void run() {
try{
ZipInputStream zis = new ZipInputStream(new FileInputStream(sourceFilePath));
unzipProcess(zis, distDirPath);
zis.close();
promise.resolve(null);
} catch (Exception e) {
promise.reject(e.getCause());
}
}
}).start();
}
@ReactMethod
public void remoteUnzip(String distDirPath, String _url, @Nullable ReadableMap headers, Promise promise){
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(_url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
if(headers != null){
Iterator<Map.Entry<String, Object>> it = headers.getEntryIterator();
while (it.hasNext()){
Map.Entry<String, Object> entry = it.next();
connection.setRequestProperty(entry.getKey(), entry.getValue().toString());
}
}
ZipInputStream zis = new ZipInputStream(connection.getInputStream());
unzipProcess(zis, distDirPath);
connection.disconnect();
promise.resolve(null);
}catch (Exception e){
promise.reject(e.getCause());
}
}
}).start();
}
private ArrayList<String> walkDir(String path) {
ArrayList<String> res = new ArrayList<>();
File node = new File(path);
if(node.isFile()){
res.add(path);
}else{
String[] children = node.list();
if(children != null){
for(String filename: children){
ArrayList<String> childPaths = walkDir(new File(path, filename).toString());
res.addAll(childPaths);
}
}
}
return res;
}
private void zipProcess(String sourceDirPath, ZipOutputStream zos) throws Exception{
ArrayList<String> paths = walkDir(sourceDirPath);
byte[] buffer = new byte[4096];
int len;
for(String path: paths){
ZipEntry zipEntry = new ZipEntry(path.replace(sourceDirPath + "/", ""));
zos.putNextEntry(zipEntry);
try (FileInputStream fis = new FileInputStream(path)) {
while ((len = fis.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
zos.close();
}
@ReactMethod
public void remoteZip(String sourceDirPath, String _url, @Nullable ReadableMap headers, Promise promise){
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(_url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
if(headers != null){
Iterator<Map.Entry<String, Object>> it = headers.getEntryIterator();
while (it.hasNext()){
Map.Entry<String, Object> entry = it.next();
connection.setRequestProperty(entry.getKey(), entry.getValue().toString());
}
}
ZipOutputStream zos = new ZipOutputStream(connection.getOutputStream());
zipProcess(sourceDirPath, zos);
if(connection.getResponseCode() == 200){
InputStream is = connection.getInputStream();
ByteArrayOutputStream result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
for (int length; (length = is.read(buffer)) != -1; ) {
result.write(buffer, 0, length);
}
is.close();
connection.disconnect();
promise.resolve(result.toString());
}else{
throw new Exception("Network request failed");
}
}catch (Exception e){
promise.reject(e.getCause());
}
}
}).start();
}
}
@@ -0,0 +1,121 @@
package com.rajarsheechatterjee.ZipArchive
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ZipArchive(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "ZipArchive"
}
@ReactMethod
fun unzip(sourceFilePath: String, distDirPath: String, promise: Promise) {
Thread {
try {
ZipFile(sourceFilePath).use { zis ->
zis.entries().asSequence().filterNot { it.isDirectory }.forEach { zipEntry ->
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
zis.getInputStream(zipEntry).use { inputStream ->
FileOutputStream(newFile).use { fos -> inputStream.copyTo(fos, 4096) }
}
}
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject(e)
}
}.start()
}
@ReactMethod
fun remoteUnzip(
distDirPath: String,
urlString: String,
headers: ReadableMap,
promise: Promise
) {
val connection = URL(urlString).openConnection() as HttpURLConnection
Thread {
try {
connection.requestMethod = "GET"
val it = headers.entryIterator
while (it.hasNext()) {
val (key, value) = it.next()
connection.setRequestProperty(key, value.toString())
}
ZipInputStream(connection.inputStream).use { zis ->
generateSequence { zis.nextEntry }
.filterNot { it.isDirectory }
.forEach { zipEntry ->
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos -> zis.copyTo(fos, 4096) }
}
}
if (connection.responseCode == 200) {
promise.resolve(null)
} else {
throw Exception("Network request failed")
}
} catch (e: Exception) {
promise.reject(e)
} finally {
connection.disconnect()
}
}.start()
}
private fun zipProcess(sourceDirPath: String, zos: ZipOutputStream) {
val sourceDir = File(sourceDirPath)
sourceDir.walkBottomUp().filter { it.isFile }.forEach { file ->
val zipFileName =
file.absolutePath.removePrefix(sourceDir.absolutePath).removePrefix("/")
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
file.inputStream().use { fis -> fis.copyTo(zos, 4096) }
}
}
@ReactMethod
fun remoteZip(
sourceDirPath: String,
urlString: String,
headers: ReadableMap,
promise: Promise
) {
Thread {
val connection = URL(urlString).openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
val it = headers.entryIterator
while (it.hasNext()) {
val (key, value) = it.next()
connection.setRequestProperty(key, value.toString())
}
ZipOutputStream(connection.outputStream).use { zipProcess(sourceDirPath, it) }
if (connection.responseCode == 200) {
promise.resolve(
connection.inputStream.bufferedReader().use { it.readText() })
} else {
throw Exception("Network request failed")
}
} catch (e: Exception) {
promise.reject(e)
} finally {
connection.disconnect()
}
}.start()
}
}
@@ -1,32 +0,0 @@
package com.rajarsheechatterjee.ZipArchive;
import androidx.annotation.NonNull;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ZipArchivePackage implements ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactApplicationContext) {
List<NativeModule> modules = new ArrayList<>();
try {
modules.add(new ZipArchive(reactApplicationContext));
} catch (Exception e) {
throw new RuntimeException(e);
}
return modules;
}
@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactApplicationContext) {
return Collections.emptyList();
}
}
@@ -0,0 +1,17 @@
package com.rajarsheechatterjee.ZipArchive
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class ZipArchivePackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext): MutableList<NativeModule> =
listOf(ZipArchive(context)).toMutableList()
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()
}
+1
View File
@@ -19,5 +19,6 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}
+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
Vendored
+3
View File
@@ -1,4 +1,7 @@
declare module '@env' {
export const MYANIMELIST_CLIENT_ID: string;
export const ANILIST_CLIENT_ID: string;
export const GIT_HASH: string;
export const RELEASE_DATE: string;
export const BUILD_TYPE: 'Debug' | 'Release' | 'Beta' | 'Github Action';
}
+10203 -11520
View File
File diff suppressed because it is too large Load Diff
+18 -12
View File
@@ -3,24 +3,26 @@
"version": "2.0.0",
"private": true,
"scripts": {
"android": "react-native run-android --active-arch-only",
"android": "npm run prestart && react-native run-android --active-arch-only",
"ios": "react-native run-ios",
"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}\"",
"buildRelease": "cd android && ./gradlew clean && ./gradlew assembleRelease",
"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-community/slider": "^4.4.2",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-google-signin/google-signin": "^11.0.0",
"@react-navigation/native": "^6.1.9",
"@react-navigation/stack": "^6.0.11",
"@shopify/flash-list": "1.4.3",
"@shopify/flash-list": "^1.4.3",
"cheerio": "^1.0.0-rc.12",
"color": "^4.2.3",
"dayjs": "^1.11.10",
@@ -33,33 +35,37 @@
"expo-linear-gradient": "~12.3.0",
"expo-linking": "~5.0.2",
"expo-localization": "~14.3.0",
"expo-navigation-bar": "^2.8.1",
"expo-notifications": "~0.20.1",
"expo-speech": "~11.3.0",
"expo-sqlite": "~11.3.3",
"expo-web-browser": "~12.3.2",
"htmlparser2": "^9.1.0",
"i18n-js": "^3.8.0",
"lodash-es": "^4.17.21",
"qs": "^6.11.2",
"protobufjs": "^7.2.6",
"react": "18.2.0",
"react-native": "0.72.10",
"react-native-background-actions": "^3.0.1",
"react-native-device-info": "^8.4.7",
"react-native-drawer-layout": "^3.2.2",
"react-native-error-boundary": "^1.1.16",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.12.0",
"react-native-lottie-splash-screen": "^1.0.1",
"react-native-mmkv": "^2.11.0",
"react-native-pager-view": "6.2.0",
"react-native-paper": "^5.12.0",
"react-native-reanimated": "~3.3.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-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",
"sanitize-html": "^2.7.0",
"react-native-webview": "^13.10.5",
"react-native-zip-archive": "^7.0.0",
"sanitize-html": "^2.13.0",
"urlencode": "^2.0.0"
},
"devDependencies": {
@@ -73,7 +79,7 @@
"@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.24",
"@types/react-native-vector-icons": "^6.4.10",
"@types/sanitize-html": "^2.6.2",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
"babel-plugin-module-resolver": "^4.1.0",
+44
View File
@@ -0,0 +1,44 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
const formattedDate = new Date().getTime();
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
const buildType = process.argv[2] || 'Beta';
let data =
`BUILD_TYPE=${buildType}` +
os.EOL +
`GIT_HASH=${commitHash}` +
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 -1
View File
@@ -6,10 +6,11 @@ import { coverPlaceholderColor } from '../theme/colors';
import color from 'color';
import { ThemeColors } from '@theme/types';
import { NovelItem } from '@plugins/types';
import { NovelInfo } from '@database/types';
interface ListViewProps {
item: NovelInfo;
item: NovelItem | NovelInfo;
downloadBadge?: React.ReactNode;
unreadBadge?: React.ReactNode;
inLibraryBadge?: React.ReactNode;
+114 -67
View File
@@ -14,12 +14,14 @@ import ListView from './ListView';
import { useDeviceOrientation } from '@hooks';
import { coverPlaceholderColor } from '../theme/colors';
import { DisplayModes } from '@screens/library/constants/constants';
import { LibraryNovelInfo, NovelInfo } from '@database/types';
import { LibraryNovelInfo } from '@database/types';
import { NovelItem } from '@plugins/types';
import { ThemeColors } from '@theme/types';
import { useLibrarySettings } from '@hooks/persisted';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { getString } from '@strings/translations';
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
import { defaultCover } from '@plugins/helpers/constants';
interface UnreadBadgeProps {
chaptersDownloaded: number;
@@ -35,23 +37,37 @@ interface DownloadBadgeProps {
theme: ThemeColors;
}
function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
item,
onPress,
libraryStatus,
theme,
isSelected,
onLongPress,
selectedNovelIds,
}: {
type CoverItemLibrary =
| LibraryNovelInfo & {
completeRow?: number;
};
type CoverItemPlugin =
| NovelItem & {
completeRow?: number;
};
interface INovelCover<TNovel> {
item: TNovel;
onPress: () => void;
libraryStatus: boolean;
theme: ThemeColors;
isSelected: boolean;
addSkeletonLoading?: boolean;
onLongPress: (item: TNovel) => void;
selectedNovelIds: number[];
}) {
}
function NovelCover<TNovel extends CoverItemLibrary | CoverItemPlugin>({
item,
onPress,
libraryStatus,
theme,
isSelected,
addSkeletonLoading,
onLongPress,
selectedNovelIds,
}: INovelCover<TNovel>) {
const {
displayMode = DisplayModes.Comfortable,
showDownloadBadges = true,
@@ -70,24 +86,33 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
const coverHeight = useMemo(
() => (window.width / numColumns) * (4 / 3),
[numColumns],
[window.width, numColumns],
);
const selectNovel = () => onLongPress(item);
const uri = item.cover;
const uri = item.cover || defaultCover;
if (item.completeRow) {
if (!addSkeletonLoading) {
return <></>;
}
return (
<SourceScreenSkeletonLoading
theme={theme}
completeRow={item.completeRow}
/>
);
}
return displayMode !== DisplayModes.List ? (
<View
style={[
{
flex: 1 / numColumns,
borderRadius: 6,
overflow: 'hidden',
margin: 2,
},
styles.standardNovelCover,
isSelected && {
backgroundColor: theme.primary,
opacity: 0.8,
...styles.selectedNovelCover,
},
]}
>
@@ -102,22 +127,26 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
onLongPress={selectNovel}
>
<View style={styles.badgeContainer}>
{libraryStatus && <InLibraryBadge theme={theme} />}
{showDownloadBadges && item.chaptersDownloaded > 0 ? (
<DownloadBadge
showUnreadBadges={showUnreadBadges}
chaptersDownloaded={item.chaptersDownloaded}
chaptersUnread={item.chaptersUnread}
theme={theme}
/>
) : null}
{showUnreadBadges && item.chaptersUnread > 0 ? (
<UnreadBadge
theme={theme}
chaptersDownloaded={item.chaptersDownloaded}
chaptersUnread={item.chaptersUnread}
showDownloadBadges={showDownloadBadges}
/>
{libraryStatus ? <InLibraryBadge theme={theme} /> : null}
{item.id ? (
<>
{showDownloadBadges && item.chaptersDownloaded > 0 ? (
<DownloadBadge
showUnreadBadges={showUnreadBadges}
chaptersDownloaded={item.chaptersDownloaded}
chaptersUnread={item.chaptersUnread}
theme={theme}
/>
) : null}
{showUnreadBadges && item.chaptersUnread > 0 ? (
<UnreadBadge
theme={theme}
chaptersDownloaded={item.chaptersDownloaded}
chaptersUnread={item.chaptersUnread}
showDownloadBadges={showDownloadBadges}
/>
) : null}
</>
) : null}
</View>
<Image
@@ -125,10 +154,10 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
style={[
{
height: coverHeight,
borderRadius: 4,
backgroundColor: coverPlaceholderColor,
},
libraryStatus && { opacity: 0.5 },
styles.standardBorderRadius,
libraryStatus && styles.opacityPoint5,
]}
/>
<View style={styles.compactTitleContainer}>
@@ -145,7 +174,7 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
<ListView
item={item}
downloadBadge={
showDownloadBadges && item.chaptersDownloaded ? (
showDownloadBadges && item.id && item.chaptersDownloaded ? (
<DownloadBadge
theme={theme}
showUnreadBadges={showUnreadBadges}
@@ -155,7 +184,7 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
) : null
}
unreadBadge={
showUnreadBadges && item.chaptersUnread ? (
showUnreadBadges && item.id && item.chaptersUnread ? (
<UnreadBadge
theme={theme}
chaptersDownloaded={item.chaptersDownloaded}
@@ -188,9 +217,9 @@ const ComfortableTitle = ({
numberOfLines={2}
style={[
styles.title,
styles.padding4,
{
color: theme.onSurface,
padding: 4,
},
]}
>
@@ -204,18 +233,7 @@ const CompactTitle = ({ novelName }: { novelName: string }) => (
colors={['transparent', 'rgba(0,0,0,0.7)']}
style={styles.linearGradient}
>
<Text
numberOfLines={2}
style={[
styles.title,
{
color: 'rgba(255,255,255,1)',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: -1, height: 1 },
textShadowRadius: 10,
},
]}
>
<Text numberOfLines={2} style={[styles.title, styles.compactTitle]}>
{novelName}
</Text>
</LinearGradient>
@@ -229,15 +247,27 @@ const InLibraryBadge = ({ theme }: { theme: ThemeColors }) => (
{
backgroundColor: theme.primary,
color: theme.onPrimary,
borderRadius: 4,
},
styles.standardBorderRadius,
]}
>
{getString('novelScreen.inLibaray')}
</Text>
);
const UnreadBadge = ({
interface BadgeProps {
chaptersDownloaded: number;
chaptersUnread: number;
theme: ThemeColors;
}
interface UnreadBadgeProps extends BadgeProps {
showDownloadBadges: boolean;
}
interface DownloadBadgeProps extends BadgeProps {
showUnreadBadges: boolean;
}
const UnreadBadge: React.FC<UnreadBadgeProps> = ({
chaptersDownloaded,
chaptersUnread,
showDownloadBadges,
@@ -246,13 +276,8 @@ const UnreadBadge = ({
<Text
style={[
styles.unreadBadge,
!chaptersDownloaded && {
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
!showDownloadBadges && {
borderRadius: 4,
},
!chaptersDownloaded && styles.LeftBorderRadius,
!showDownloadBadges && styles.standardBorderRadius,
{
backgroundColor: theme.primary,
color: theme.onPrimary,
@@ -263,7 +288,7 @@ const UnreadBadge = ({
</Text>
);
const DownloadBadge = ({
const DownloadBadge: React.FC<DownloadBadgeProps> = ({
chaptersDownloaded,
showUnreadBadges,
chaptersUnread,
@@ -272,13 +297,8 @@ const DownloadBadge = ({
<Text
style={[
styles.downloadBadge,
!chaptersUnread && {
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
!showUnreadBadges && {
borderRadius: 4,
},
!chaptersUnread && styles.RightBorderRadius,
!showUnreadBadges && styles.standardBorderRadius,
{
backgroundColor: theme.tertiary,
color: theme.onTertiary,
@@ -290,6 +310,19 @@ const DownloadBadge = ({
);
const styles = StyleSheet.create({
LeftBorderRadius: {
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
RightBorderRadius: {
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
standardBorderRadius: {
borderRadius: 4,
},
opacityPoint5: { opacity: 0.5 },
padding4: { padding: 4 },
titleContainer: {
flex: 1,
borderRadius: 4,
@@ -353,4 +386,18 @@ const styles = StyleSheet.create({
left: 10,
flexDirection: 'row',
},
standardNovelCover: {
borderRadius: 6,
overflow: 'hidden',
margin: 2,
},
selectedNovelCover: {
opacity: 0.8,
},
compactTitle: {
color: 'rgba(255,255,255,1)',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: -1, height: 1 },
textShadowRadius: 10,
},
});
+38 -8
View File
@@ -8,19 +8,24 @@ import {
ListRenderItem,
} from 'react-native';
import { NovelItem } from '@plugins/types';
import { LibraryNovelInfo, NovelInfo } from '../database/types';
import { LibraryNovelInfo } from '../database/types';
import { useDeviceOrientation } from '@hooks';
export type NovelListRenderItem = ListRenderItem<
LibraryNovelInfo | NovelInfo | NovelItem
>;
export type NovelListRenderItem = ListRenderItem<LibraryNovelInfo | NovelItem>;
const NovelList: React.FC<
FlatListProps<LibraryNovelInfo | NovelInfo | NovelItem>
> = props => {
type listDataItem =
| (LibraryNovelInfo | NovelItem) & {
completeRow?: number;
};
interface NovelListProps extends FlatListProps<LibraryNovelInfo | NovelItem> {
inSource?: boolean;
data: Array<listDataItem>;
}
const NovelList: React.FC<NovelListProps> = props => {
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
useLibrarySettings();
const orientation = useDeviceOrientation();
const isListView = displayMode === DisplayModes.List;
@@ -37,6 +42,30 @@ const NovelList: React.FC<
}
}, [isListView, orientation, novelsPerRow]);
var extendedNovelList: Array<listDataItem> = props?.data;
if (props.data?.length && props.inSource) {
let remainder = numColumns - (props.data?.length % numColumns);
let extension: Array<listDataItem> = [];
if (remainder !== 0 && remainder !== numColumns) {
for (let i = 0; i < remainder; i++) {
extension.push({
cover: '',
name: '',
path: 'loading-' + remainder,
completeRow: 1,
} as listDataItem);
}
}
extension.push({
cover: '',
name: '',
path: 'loading-' + remainder,
completeRow: 2,
} as listDataItem);
extendedNovelList = [...props.data, ...extension];
}
return (
<FlatList
contentContainerStyle={[
@@ -47,6 +76,7 @@ const NovelList: React.FC<
key={numColumns}
keyExtractor={(item, index) => index + '_' + item.path}
{...props}
data={extendedNovelList}
/>
);
};
+42 -1
View File
@@ -1,9 +1,10 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { Pressable, StyleSheet, View, TextInput } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
import { ThemeColors } from '../../theme/types';
import { Menu } from 'react-native-paper';
interface RightIcon {
iconName: string;
@@ -11,6 +12,11 @@ interface RightIcon {
onPress: () => void;
}
interface MenuButton {
title: string;
onPress: () => void;
}
interface SearcbarProps {
searchText: string;
placeholder: string;
@@ -18,6 +24,7 @@ interface SearcbarProps {
onSubmitEditing?: () => void;
leftIcon: string;
rightIcons?: RightIcon[];
menuButtons?: MenuButton[];
handleBackAction?: () => void;
clearSearchbar: () => void;
onLeftIconPress?: () => void;
@@ -31,6 +38,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
onSubmitEditing,
leftIcon,
rightIcons,
menuButtons,
handleBackAction,
clearSearchbar,
onLeftIconPress,
@@ -38,6 +46,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
}) => {
const searchbarRef = useRef<any>(null);
const focusSearchbar = () => searchbarRef.current.focus();
const [extraMenu, showExtraMenu] = useState(false);
const { top, right, left } = useSafeAreaInsets();
const marginTop = top + 8;
@@ -100,6 +109,38 @@ const Searchbar: React.FC<SearcbarProps> = ({
theme={theme}
/>
))}
{menuButtons?.length ? (
<Menu
visible={extraMenu}
onDismiss={() => showExtraMenu(false)}
anchor={
<IconButtonV2
name="dots-vertical"
color={theme.onSurface}
onPress={() => showExtraMenu(true)}
theme={theme}
/>
}
contentStyle={{
backgroundColor: theme.surface2,
}}
>
{menuButtons?.map((button, index) => (
<Menu.Item
key={index}
title={button.title}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
showExtraMenu(false);
button.onPress();
}}
/>
))}
</Menu>
) : null}
</Pressable>
</View>
);
@@ -52,6 +52,7 @@ export const ThemePicker = ({
elevation: 2,
borderRadius: 50,
padding: 1.6,
zIndex: 1,
}}
/>
) : null}
+7 -1
View File
@@ -12,10 +12,11 @@ import {
} from './tables/ChapterTable';
import { dbTxnErrorCallback } from './utils/helpers';
import { noop } from 'lodash-es';
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, () => {});
@@ -28,6 +29,10 @@ export const createTables = () => {
tx.executeSql(createChapterTableQuery);
tx.executeSql(createChapterNovelIdIndexQuery);
});
db.transaction(tx => {
tx.executeSql(createRepositoryTableQuery);
});
};
/**
@@ -41,6 +46,7 @@ export const deleteDatabase = async () => {
tx.executeSql('DROP TABLE NovelCategory');
tx.executeSql('DROP TABLE Chapter');
tx.executeSql('DROP TABLE Download');
tx.executeSql('DROP TABLE Repository');
},
dbTxnErrorCallback,
noop,
+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
+62 -118
View File
@@ -1,20 +1,14 @@
import RNFS from 'react-native-fs';
import * as SQLite from 'expo-sqlite';
import { showToast } from '@utils/showToast';
import { getPlugin } from '@plugins/pluginManager';
import { ChapterInfo, DownloadedChapter } from '../types';
import { ChapterItem } from '@plugins/types';
import * as cheerio from 'cheerio';
import { NovelDownloadFolder } from '@utils/constants/download';
import { txnErrorCallback } from '@database/utils/helpers';
import { Plugin } from '@plugins/types';
import { Update } from '../types';
import { noop } from 'lodash-es';
import { getString } from '@strings/translations';
import TextFile from '@native/TextFile';
const db = SQLite.openDatabase('lnreader.db');
import FileManager from '@native/FileManager';
import { NOVEL_STORAGE } from '@utils/Storages';
import { db } from '@database/db';
const insertChapterQuery = `
INSERT OR IGNORE INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position)
@@ -42,12 +36,12 @@ export const insertChapters = async (
index,
],
(txObj, { insertId }) => {
if (!insertId) {
if (!insertId || insertId < 0) {
tx.executeSql(
`
UPDATE Chapter SET
page = ?, position = ?
WHERE path = ? AND novelId = ? (AND page != ? OR position != ?)
WHERE path = ? AND novelId = ? AND (page != ? OR position != ?)
`,
[
chapter.page || '1',
@@ -65,12 +59,8 @@ export const insertChapters = async (
});
};
const getPageChaptersQuery = (
sort = 'ORDER BY position ASC',
filter = '',
page = '1',
) =>
`SELECT * FROM Chapter WHERE novelId = ? AND page = '${page}' ${filter} ${sort}`;
const getPageChaptersQuery = (sort = 'ORDER BY position ASC', filter = '') =>
`SELECT * FROM Chapter WHERE novelId = ? AND page = ? ${filter} ${sort}`;
export const getCustomPages = (
novelId: number,
@@ -99,6 +89,21 @@ export const getNovelChapters = (novelId: number): Promise<ChapterInfo[]> => {
);
};
export const getChapter = async (
chapterId: number,
): Promise<ChapterInfo | null> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
'SELECT * FROM Chapter WHERE id = ?',
[chapterId],
(txObj, { rows }) => resolve(rows.item(0)),
txnErrorCallback,
);
}),
);
};
export const getPageChapters = (
novelId: number,
sort?: string,
@@ -108,8 +113,8 @@ export const getPageChapters = (
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
getPageChaptersQuery(sort, filter, page),
[novelId],
getPageChaptersQuery(sort, filter),
[novelId, page || '1'],
(txObj, { rows }) => resolve((rows as any)._array),
txnErrorCallback,
);
@@ -131,7 +136,7 @@ const getPrevChapterQuery = `
export const getPrevChapter = (
novelId: number,
chapterId: number,
): Promise<ChapterInfo> => {
): Promise<ChapterInfo | null> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
@@ -162,7 +167,7 @@ const getNextChapterQuery = `
export const getNextChapter = (
novelId: number,
chapterId: number,
): Promise<ChapterInfo> => {
): Promise<ChapterInfo | null> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
@@ -189,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) => {
@@ -197,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 = ?';
@@ -220,109 +247,14 @@ export const markAllChaptersUnread = async (novelId: number) => {
});
};
const createChapterFolder = async (
path: string,
data: {
pluginId: string;
novelId: number;
chapterId: number;
},
): Promise<string> => {
const mkdirIfNot = async (p: string, nomedia: boolean) => {
const nomediaPath =
p + (p.charAt(p.length - 1) === '/' ? '' : '/') + '.nomedia';
if (!(await RNFS.exists(p))) {
await RNFS.mkdir(p);
if (nomedia) {
await TextFile.writeFile(nomediaPath, ',');
}
}
};
await mkdirIfNot(path, false);
const { pluginId, novelId, chapterId } = data;
await mkdirIfNot(`${path}/${pluginId}/${novelId}/${chapterId}/`, true);
return `${path}/${pluginId}/${novelId}/${chapterId}/`;
};
const downloadFiles = async (
html: string,
plugin: Plugin,
novelId: number,
chapterId: number,
): Promise<void> => {
try {
const folder = await createChapterFolder(NovelDownloadFolder, {
pluginId: plugin.id,
novelId,
chapterId,
}).catch(error => {
throw error;
});
const loadedCheerio = cheerio.load(html);
const imgs = loadedCheerio('img').toArray();
for (let i = 0; i < imgs.length; i++) {
const elem = loadedCheerio(imgs[i]);
const url = elem.attr('src');
if (url) {
const imageb64 = await plugin.fetchImage(url);
const fileurl = folder + i + '.b64.png';
elem.attr('src', `file://${fileurl}`);
RNFS.writeFile(fileurl, imageb64, 'base64');
}
}
TextFile.writeFile(folder + 'index.html', loadedCheerio.html());
} catch (error) {
throw error;
}
};
// novelId for determine folder %LNReaderDownloadDir%/novelId/ChapterId/
export const downloadChapter = async (
pluginId: string,
novelId: number,
chapterId: number,
chapterPath: string,
) => {
try {
const plugin = getPlugin(pluginId);
if (!plugin) {
throw new Error(getString('downloadScreen.pluginNotFound'));
}
const chapterText = await plugin.parseChapter(chapterPath);
if (chapterText && chapterText.length) {
await downloadFiles(chapterText, plugin, novelId, chapterId);
db.transaction(tx => {
tx.executeSql(
"UPDATE Chapter SET isDownloaded = 1, updatedTime = datetime('now','localtime') WHERE id = ?",
[chapterId],
);
});
} else {
throw new Error(getString('downloadScreen.chapterEmptyOrScrapeError'));
}
} catch (error) {
throw error;
}
};
const deleteDownloadedFiles = async (
pluginId: string,
novelId: number,
chapterId: number,
) => {
try {
const path = await createChapterFolder(NovelDownloadFolder, {
pluginId,
novelId,
chapterId,
});
const files = await RNFS.readDir(path);
for (let i = 0; i < files.length; i++) {
await RNFS.unlink(files[i].path);
}
await RNFS.unlink(path);
const chapterFolder = `${NOVEL_STORAGE}/${pluginId}/${novelId}/${chapterId}`;
await FileManager.unlink(chapterFolder);
} catch (error) {
throw new Error(getString('novelScreen.deleteChapterError'));
}
@@ -431,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 = ?';
+4 -3
View File
@@ -1,18 +1,19 @@
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';
const getHistoryQuery = `
SELECT
Chapter.*, Novel.pluginId, Novel.name as novelName, Novel.path as novelPath, Novel.cover as novelCover
Chapter.*, Novel.pluginId, Novel.name as novelName, Novel.path as novelPath, Novel.cover as novelCover, Novel.id as novelId
FROM Chapter
JOIN Novel
ON Chapter.novelId = Novel.id AND Chapter.readTime IS NOT NULL
GROUP BY novelId
HAVING readTime = MAX(readTime)
ORDER BY readTime DESC
`;
+8 -6
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,
@@ -41,7 +39,7 @@ export const getLibraryNovelsFromDb = (
let getLibraryNovelsQuery = 'SELECT * FROM Novel WHERE inLibrary = 1';
if (onlyOngoingNovels) {
getLibraryNovelsQuery += " AND status NOT LIKE 'Completed'";
getLibraryNovelsQuery += " AND status = 'Ongoing'";
}
return new Promise(resolve =>
@@ -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,
);
+45 -31
View File
@@ -1,10 +1,6 @@
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabase('lnreader.db');
import * as DocumentPicker from 'expo-document-picker';
import * as RNFS from 'react-native-fs';
import { fetchImage, fetchNovel } from '@services/plugin/fetch';
import { fetchNovel } from '@services/plugin/fetch';
import { insertChapters } from './ChapterQueries';
import { showToast } from '@utils/showToast';
@@ -13,7 +9,11 @@ import { noop } from 'lodash-es';
import { getString } from '@strings/translations';
import { BackupNovel, NovelInfo } from '../types';
import { SourceNovel } from '@plugins/types';
import { NovelDownloadFolder } from '@utils/constants/download';
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,
@@ -43,28 +43,25 @@ export const insertNovelAndChapters = async (
});
});
if (novelId) {
const promises = [insertChapters(novelId, sourceNovel.chapters)];
if (sourceNovel.cover) {
const novelDir = NovelDownloadFolder + '/' + pluginId + '/' + novelId;
const novelCoverUri = 'file://' + novelDir + '/cover.png';
promises.push(
fetchImage(pluginId, sourceNovel.cover).then(base64 => {
if (base64) {
RNFS.mkdir(novelDir)
.then(() => RNFS.writeFile(novelCoverUri, base64, 'base64'))
.then(() => {
db.transaction(tx => {
tx.executeSql('UPDATE Novel SET cover = ? WHERE id = ?', [
novelCoverUri,
novelId,
]);
});
});
}
}),
);
const novelDir = NOVEL_STORAGE + '/' + pluginId + '/' + novelId;
await FileManager.mkdir(novelDir);
const novelCoverPath = novelDir + '/cover.png';
const novelCoverUri = 'file://' + novelCoverPath;
downloadFile(
sourceNovel.cover,
novelCoverPath,
getPlugin(pluginId)?.imageRequestInit,
).then(() => {
db.transaction(tx => {
tx.executeSql('UPDATE Novel SET cover = ? WHERE id = ?', [
novelCoverUri,
novelId,
]);
});
});
}
await Promise.all(promises);
await insertChapters(novelId, sourceNovel.chapters);
}
return novelId;
};
@@ -79,7 +76,22 @@ export const getAllNovels = async (): Promise<NovelInfo[]> => {
);
};
export const getNovel = async (
export const getNovelById = async (
novelId: number,
): Promise<NovelInfo | null> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
'SELECT * FROM Novel WHERE id = ?',
[novelId],
(txObj, { rows }) => resolve(rows.item(0)),
txnErrorCallback,
);
}),
);
};
export const getNovelByPath = async (
novelPath: string,
pluginId: string,
): Promise<NovelInfo | null> => {
@@ -102,7 +114,7 @@ export const switchNovelToLibrary = async (
novelPath: string,
pluginId: string,
) => {
const novel = await getNovel(novelPath, pluginId);
const novel = await getNovelByPath(novelPath, pluginId);
if (novel) {
db.transaction(tx => {
tx.executeSql(
@@ -271,10 +283,12 @@ export const updateNovelInfo = async (info: NovelInfo) => {
export const pickCustomNovelCover = async (novel: NovelInfo) => {
const image = await DocumentPicker.getDocumentAsync({ type: 'image/*' });
if (image.assets && image.assets[0]) {
const novelDir =
NovelDownloadFolder + '/' + novel.pluginId + '/' + novel.id;
const novelDir = NOVEL_STORAGE + '/' + novel.pluginId + '/' + novel.id;
let novelCoverUri = 'file://' + novelDir + '/cover.png';
RNFS.copyFile(image.assets[0].uri, novelCoverUri);
if (!(await FileManager.exists(novelDir))) {
await FileManager.mkdir(novelDir);
}
FileManager.copyFile(image.assets[0].uri, novelCoverUri);
novelCoverUri += '?' + Date.now();
db.transaction(tx => {
tx.executeSql(
+62
View File
@@ -0,0 +1,62 @@
import { Repository } from '@database/types';
import { txnErrorCallback } from '../utils/helpers';
import { noop } from 'lodash-es';
import { db } from '@database/db';
const getRepositoriesQuery = 'SELECT * FROM Repository';
export const getRepositoriesFromDb = async (): Promise<Repository[]> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
getRepositoriesQuery,
[],
(txObj, { rows }) => resolve((rows as any)._array),
txnErrorCallback,
);
}),
);
};
const isRepoUrlDuplicateQuery = `
SELECT COUNT(*) as isDuplicate FROM Repository WHERE url = ?
`;
export const isRepoUrlDuplicate = (repoUrl: string): Promise<boolean> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
isRepoUrlDuplicateQuery,
[repoUrl],
(txObj, { rows }) => {
const { _array } = rows as any;
resolve(Boolean(_array[0]?.isDuplicate));
},
txnErrorCallback,
);
}),
);
};
const createRepositoryQuery = 'INSERT INTO Repository (url) VALUES (?)';
export const createRepository = (repoUrl: string): void =>
db.transaction(tx =>
tx.executeSql(createRepositoryQuery, [repoUrl], noop, txnErrorCallback),
);
const deleteRepositoryQuery = 'DELETE FROM Repository WHERE id = ?';
export const deleteRepositoryById = (id: number): void => {
db.transaction(tx => {
tx.executeSql(deleteRepositoryQuery, [id], noop, txnErrorCallback);
});
};
const updateRepositoryQuery = 'UPDATE Repository SET name = ? WHERE id = ?';
export const updateRepository = (id: number, url: string): void =>
db.transaction(tx =>
tx.executeSql(updateRepositoryQuery, [url, id], noop, txnErrorCallback),
);
+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
+7
View File
@@ -0,0 +1,7 @@
export const createRepositoryTableQuery = `
CREATE TABLE IF NOT EXISTS Repository (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
UNIQUE(url)
);
`;
+6
View File
@@ -35,6 +35,7 @@ export interface ChapterInfo {
chapterNumber?: number;
page: string;
progress: number | null;
position?: number;
}
export interface DownloadedChapter extends ChapterInfo {
@@ -93,3 +94,8 @@ export interface BackupNovel extends NovelInfo {
export interface BackupCategory extends Category {
novelIds: number[];
}
export interface Repository {
id: number;
url: string;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { appVersion } from '../../utils/versionUtils';
import { version } from '../../../package.json';
import { newer } from '@utils/compareVersion';
interface GithubUpdate {
@@ -29,7 +29,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
};
const isNewVersion = (versionTag: string) => {
let currentVersion = `${appVersion}`;
let currentVersion = `${version}`;
const regex = /[^\\d.]/;
let newVersion = versionTag.replace(regex, '');
+33 -21
View File
@@ -1,18 +1,17 @@
import { useCallback, useEffect } from 'react';
import { StatusBar } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import color from 'color';
import {
changeNavigationBarColor,
hideNavigationBar,
showNavigationBar,
} from '../../native/NavigationBarColor';
import {
useChapterGeneralSettings,
useChapterReaderSettings,
useTheme,
} from '../persisted';
import Color from 'color';
import * as NavigationBar from 'expo-navigation-bar';
import {
changeNavigationBarColor,
setStatusBarColor,
} from '@theme/utils/setBarColor';
const useFullscreenMode = () => {
const { addListener } = useNavigation();
@@ -23,21 +22,37 @@ const useFullscreenMode = () => {
const setImmersiveMode = useCallback(() => {
if (fullScreenMode) {
StatusBar.setHidden(true);
hideNavigationBar();
NavigationBar.setVisibilityAsync('hidden');
} else {
StatusBar.setBarStyle(
color(backgroundColor).isDark() ? 'light-content' : 'dark-content',
setStatusBarColor(Color(backgroundColor));
changeNavigationBarColor(
backgroundColor,
Color(backgroundColor).isDark(),
);
StatusBar.setBackgroundColor(backgroundColor);
changeNavigationBarColor(backgroundColor);
}
}, [backgroundColor, fullScreenMode]);
const showStatusAndNavBar = useCallback(() => {
StatusBar.setHidden(false);
showNavigationBar();
changeNavigationBarColor(Color(theme.surface).hex(), !theme.isDark);
}, []);
NavigationBar.setVisibilityAsync('visible');
if (fullScreenMode) {
/**
* This is overlay of reader footer and should be transparent.
* But in hexa, ##xxxxxx00 could be another color
*/
changeNavigationBarColor(
Color(theme.surface).alpha(0.01).hexa(),
theme.isDark,
);
setStatusBarColor(theme);
} else {
changeNavigationBarColor(
backgroundColor,
Color(backgroundColor).isDark(),
);
}
}, [backgroundColor, fullScreenMode]);
useEffect(() => {
setImmersiveMode();
@@ -45,13 +60,10 @@ const useFullscreenMode = () => {
useEffect(() => {
const unsubscribe = addListener('beforeRemove', () => {
showStatusAndNavBar();
StatusBar.setBarStyle(theme.isDark ? 'light-content' : 'dark-content');
changeNavigationBarColor(
Color(theme.surface2).hex(),
!theme.isDark,
true,
);
StatusBar.setHidden(false);
NavigationBar.setVisibilityAsync('visible');
setStatusBarColor(theme);
changeNavigationBarColor(Color(theme.surface2).hex(), theme.isDark);
});
return unsubscribe;
+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]);
+34 -121
View File
@@ -1,136 +1,49 @@
import { ChapterInfo, NovelInfo } from '@database/types';
import { BACKGROUND_ACTION, BackgoundAction } from '@services/constants';
import { MMKVStorage, getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
import { showToast } from '@utils/showToast';
import { getString } from '@strings/translations';
import BackgroundService from 'react-native-background-actions';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import { useMemo } from 'react';
import { useMMKVObject } from 'react-native-mmkv';
import * as Notifications from 'expo-notifications';
import { downloadChapter } from '@database/queries/ChapterQueries';
import { sleep } from '@utils/sleep';
export const DOWNLOAD_QUEUE = 'DOWNLOAD';
export const CHAPTER_DOWNLOADING = 'CHAPTER_DOWNLOADING';
interface DownloadData {
novel: NovelInfo;
chapter: ChapterInfo;
}
interface TaskData {
delay: number;
}
const defaultQueue: DownloadData[] = [];
const downloadChapterAction = async (taskData?: TaskData) => {
try {
MMKVStorage.set(BACKGROUND_ACTION, BackgoundAction.DOWNLOAD_CHAPTER);
let queue = getMMKVObject<DownloadData[]>(DOWNLOAD_QUEUE) || [];
while (queue.length > 0 && BackgroundService.isRunning()) {
const { novel, chapter } = queue[0];
await BackgroundService.updateNotification({
taskTitle: getString('downloadScreen.downloadingNovel', {
name: novel.name,
}),
taskDesc: getString('downloadScreen.chapterName', {
name: chapter.name,
}),
});
await downloadChapter(
novel.pluginId,
novel.id,
chapter.id,
chapter.path,
).catch((error: Error) =>
Notifications.scheduleNotificationAsync({
content: {
title: chapter.name,
body: getString('downloadScreen.failed', {
message: error.message,
}),
},
trigger: null,
}),
);
// get the newest queue;
queue = getMMKVObject<DownloadData[]>(DOWNLOAD_QUEUE) || [];
setMMKVObject(DOWNLOAD_QUEUE, queue.slice(1));
queue = getMMKVObject<DownloadData[]>(DOWNLOAD_QUEUE) || [];
if (taskData) {
await sleep(taskData?.delay);
}
}
} finally {
MMKVStorage.delete(BACKGROUND_ACTION);
if (getMMKVObject<DownloadData[]>(DOWNLOAD_QUEUE)?.length === 0) {
await Notifications.scheduleNotificationAsync({
content: {
title: getString('downloadScreen.downloader'),
body: getString('downloadScreen.completed'),
},
trigger: null,
});
}
BackgroundService.stop();
}
};
export default function useDownload() {
const [queue = defaultQueue, setQueue] =
useMMKVObject<DownloadData[]>(DOWNLOAD_QUEUE);
const [queue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
);
const downloadQueue = useMemo(
() => queue?.filter(t => t.name === 'DOWNLOAD_CHAPTER') || [],
[queue],
);
const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) => {
setQueue([...queue, { novel, chapter }]);
resumeDowndload();
};
const downloadChapters = (novel: NovelInfo, chapters: ChapterInfo[]) => {
setQueue([
...queue,
...chapters.map(chapter => {
return { novel, chapter };
}),
]);
resumeDowndload();
};
const resumeDowndload = () => {
const currentAction = MMKVStorage.getString(BACKGROUND_ACTION) as
| BackgoundAction
| undefined;
if (!currentAction || currentAction === BackgoundAction.DOWNLOAD_CHAPTER) {
if (!BackgroundService.isRunning()) {
BackgroundService.start(downloadChapterAction, {
taskName: 'Download chapters',
taskTitle: getString('downloadScreen.downloading'),
taskDesc: getString('common.preparing'),
taskIcon: { name: 'notification_icon', type: 'drawable' },
color: '#00adb5',
parameters: { delay: 1000 },
linkingURI: 'lnreader://updates',
});
}
} else {
showToast(getString('downloadScreen.serviceRunning'));
}
};
const pauseDownload = () => {
BackgroundService.stop().finally(() => {
MMKVStorage.delete(BACKGROUND_ACTION);
const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) =>
ServiceManager.manager.addTask({
name: 'DOWNLOAD_CHAPTER',
data: {
chapterId: chapter.id,
novelName: novel.name,
chapterName: chapter.name,
},
});
};
const downloadChapters = (novel: NovelInfo, chapters: ChapterInfo[]) =>
ServiceManager.manager.addTask(
chapters.map(chapter => ({
name: 'DOWNLOAD_CHAPTER',
data: {
chapterId: chapter.id,
novelName: novel.name,
chapterName: chapter.name,
},
})),
);
const resumeDowndload = () => ServiceManager.manager.resume();
const cancelDownload = () => {
BackgroundService.stop().finally(() => {
setQueue([]);
MMKVStorage.delete(BACKGROUND_ACTION);
});
};
const pauseDownload = () => ServiceManager.manager.pause();
const cancelDownload = () =>
ServiceManager.manager.removeTasksByName('DOWNLOAD_CHAPTER');
return {
queue,
downloadQueue,
resumeDowndload,
downloadChapter,
downloadChapters,
+41 -65
View File
@@ -2,9 +2,9 @@ import { SearchResult, UserListEntry } from '@services/Trackers';
import { useMMKVNumber, useMMKVObject } from 'react-native-mmkv';
import { TrackerMetadata, getTracker } from './useTracker';
import { ChapterInfo, NovelInfo } from '@database/types';
import { MMKVStorage, getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import {
getNovel as _getNovel,
getNovelByPath,
deleteCachedNovels as _deleteCachedNovels,
getCachedNovels as _getCachedNovels,
insertNovelAndChapters,
@@ -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,
@@ -25,21 +26,19 @@ import {
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
import { showToast } from '@utils/showToast';
import { useCallback, useEffect, useState } from 'react';
import { NovelDownloadFolder } from '@utils/constants/download';
import * as RNFS from 'react-native-fs';
import { getString } from '@strings/translations';
import { ChapterItem } from '@plugins/types';
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>',
export const TRACKED_NOVEL_PREFIX = 'TRACKED_NOVEL_PREFIX';
export const NOVEL_LATEST_CHAPTER_PREFIX = 'NOVEL_LATEST_CHAPTER';
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
export const NOVEL_PAGE_UPDATES_PREFIX = 'NOVEL_PAGES_UPDATES_PREFIX';
export const NOVEL_SETTINSG_PREFIX = 'NOVEL_SETTINGS';
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
@@ -51,7 +50,9 @@ export interface NovelSettings {
showChapterTitles?: boolean;
}
const defaultNovelSettings: NovelSettings = {};
const defaultNovelSettings: NovelSettings = {
showChapterTitles: true,
};
const defaultPageIndex = 0;
export const useTrackedNovel = (novelId: number) => {
@@ -146,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);
}, []);
@@ -154,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) {
@@ -230,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,
@@ -256,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,
@@ -316,13 +325,13 @@ export const useNovel = (novelPath: string, pluginId: string) => {
);
const getNovel = useCallback(async () => {
let novel = await _getNovel(novelPath, pluginId);
let novel = await getNovelByPath(novelPath, pluginId);
if (!novel) {
const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => {
throw new Error(getString('updatesScreen.unableToGetNovel'));
});
await insertNovelAndChapters(pluginId, sourceNovel);
novel = await _getNovel(novelPath, pluginId);
novel = await getNovelByPath(novelPath, pluginId);
if (!novel) {
return;
}
@@ -332,39 +341,28 @@ export const useNovel = (novelPath: string, pluginId: string) => {
pages = Array(novel.totalPages)
.fill(0)
.map((v, idx) => String(idx + 1));
const key = `${NOVEL_PAGE_UPDATES_PREFIX}_${novel.id}`;
const hasUpdates = getMMKVObject<boolean[]>(key);
if (hasUpdates) {
if (pages.length > hasUpdates.length) {
setMMKVObject(
key,
hasUpdates.concat(
Array(pages.length - hasUpdates.length).fill(false),
),
);
}
} else {
setMMKVObject(key, Array(novel.totalPages).fill(false));
}
} else {
pages = (await getCustomPages(novel.id)).map(c => c.page);
}
setPages(pages);
if (pages.length) {
setPages(pages);
} else {
setPages(['1']);
}
setNovel(novel);
setLoading(false);
}, []);
const getChapters = useCallback(async () => {
const page = pages[pageIndex];
if (novel && page) {
const hasUpdatesKey = `${NOVEL_PAGE_UPDATES_PREFIX}_${novel.id}`;
const hasUpdates = getMMKVObject<boolean[]>(hasUpdatesKey);
let chapters = await _getPageChapters(
novel.id,
novelSettings.sort,
sort,
novelSettings.filter,
page,
);
if (hasUpdates && (hasUpdates[pageIndex] || !chapters.length)) {
if (!chapters.length && Number(page)) {
const sourcePage = await fetchPage(pluginId, novelPath, page);
const sourceChapters = sourcePage.chapters.map(ch => {
return {
@@ -373,42 +371,23 @@ export const useNovel = (novelPath: string, pluginId: string) => {
};
});
await insertChapters(novel.id, sourceChapters);
const latestChapkey = `${NOVEL_LATEST_CHAPTER_PREFIX}_${novel.id}`;
const latestChapter = getMMKVObject<ChapterItem>(latestChapkey);
if (
sourcePage.latestChapter &&
sourcePage.latestChapter.path !== latestChapter?.path
) {
setMMKVObject(latestChapkey, sourcePage.latestChapter);
setMMKVObject(
hasUpdatesKey,
hasUpdates?.map((val, idx) => {
if (idx !== pageIndex) {
return false;
}
return true;
}),
);
} else {
hasUpdates[pageIndex] = false;
setMMKVObject(hasUpdatesKey, hasUpdates);
}
chapters = await _getPageChapters(
novel.id,
novelSettings.sort,
sort,
novelSettings.filter,
page,
);
}
setChapters(chapters);
}
setLoading(false);
}, [novel, novelSettings, pageIndex]);
useEffect(() => {
getNovel();
}, []);
useEffect(() => {
getChapters();
getChapters().catch(e => showToast(e.message));
}, [getChapters]);
return {
@@ -443,8 +422,6 @@ export const deleteCachedNovels = async () => {
const cachedNovels = await _getCachedNovels();
for (let novel of cachedNovels) {
MMKVStorage.delete(`${TRACKED_NOVEL_PREFIX}_${novel.id}`);
MMKVStorage.delete(`${NOVEL_LATEST_CHAPTER_PREFIX}_${novel.id}`);
MMKVStorage.delete(`${NOVEL_PAGE_UPDATES_PREFIX}_${novel.id}`);
MMKVStorage.delete(
`${NOVEL_PAGE_INDEX_PREFIX}_${novel.pluginId}_${novel.path}`,
);
@@ -452,10 +429,9 @@ export const deleteCachedNovels = async () => {
`${NOVEL_SETTINSG_PREFIX}_${novel.pluginId}_${novel.path}`,
);
MMKVStorage.delete(`${LAST_READ_PREFIX}_${novel.pluginId}_${novel.path}`);
const novelDir =
NovelDownloadFolder + '/' + novel.pluginId + '/' + novel.id;
if (await RNFS.exists(novelDir)) {
await RNFS.unlink(novelDir);
const novelDir = NOVEL_STORAGE + '/' + novel.pluginId + '/' + novel.id;
if (await FileManager.exists(novelDir)) {
await FileManager.unlink(novelDir);
}
}
_deleteCachedNovels();
+39 -56
View File
@@ -1,5 +1,5 @@
import { locale } from 'expo-localization';
import { Language, languagesMapping } from '@utils/constants/languages';
import { languagesMapping } from '@utils/constants/languages';
import { orderBy } from 'lodash-es';
import { useMMKVObject } from 'react-native-mmkv';
import { PluginItem } from '@plugins/types';
@@ -23,17 +23,13 @@ export const FILTERED_INSTALLED_PLUGINS = 'FILTERED_INSTALLED_PLUGINS';
const defaultLang = languagesMapping[locale.split('-')[0]] || 'English';
export type PluginsMap = Record<Language, PluginItem[] | undefined>;
export default function usePlugins() {
const [lastUsedPlugin, setLastUsedPlugin] =
useMMKVObject<PluginItem>(LAST_USED_PLUGIN);
const [languagesFilter = [defaultLang], setLanguagesFilter] =
useMMKVObject<Language[]>(LANGUAGES_FILTER);
const [
filteredAvailablePlugins = {} as PluginsMap,
setFilteredAvailablePlugins,
] = useMMKVObject<PluginsMap>(FILTERED_AVAILABLE_PLUGINS);
useMMKVObject<string[]>(LANGUAGES_FILTER);
const [filteredAvailablePlugins = [], setFilteredAvailablePlugins] =
useMMKVObject<PluginItem[]>(FILTERED_AVAILABLE_PLUGINS);
const [filteredInstalledPlugins = [], setFilteredInstalledPlugins] =
useMMKVObject<PluginItem[]>(FILTERED_INSTALLED_PLUGINS);
/**
@@ -41,54 +37,58 @@ export default function usePlugins() {
* We cant use the languagesFilter directly because it is updated only after component's lifecycle end.
* And toggleLanguagFilter triggers filterPlugins before lifecycle end.
*/
const filterPlugins = useCallback((filter: Language[]) => {
const filterPlugins = useCallback((filter: string[]) => {
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS) || [];
const availablePlugins =
getMMKVObject<PluginsMap>(AVAILABLE_PLUGINS) || ({} as PluginsMap);
getMMKVObject<PluginItem[]>(AVAILABLE_PLUGINS) || [];
setFilteredInstalledPlugins(
installedPlugins.filter(plg => filter.includes(plg.lang)),
);
setFilteredAvailablePlugins(
filter.reduce((pre, cur) => {
pre[cur] = orderBy(availablePlugins[cur], 'name');
return pre;
}, {} as PluginsMap),
orderBy(
availablePlugins
.filter(
avalilablePlugin =>
!installedPlugins.some(
installedPlugin => installedPlugin.id === avalilablePlugin.id,
),
)
.filter(plg => filter.includes(plg.lang)),
'name',
),
);
}, []);
const refreshPlugins = useCallback(() => {
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS) || [];
return fetchPlugins().then(fetchedPluginsMap => {
for (const key in fetchedPluginsMap) {
const lang = key as Language;
fetchedPluginsMap[lang] = fetchedPluginsMap[lang].filter(plg => {
const finded = installedPlugins.find(v => v.id === plg.id);
if (finded) {
if (newer(plg.version, finded.version)) {
finded.hasUpdate = true;
finded.iconUrl = plg.iconUrl;
finded.url = plg.url;
if (finded.id === lastUsedPlugin?.id) {
setLastUsedPlugin(finded);
}
return fetchPlugins().then(fetchedPlugins => {
fetchedPlugins.filter(plg => {
const finded = installedPlugins.find(v => v.id === plg.id);
if (finded) {
if (newer(plg.version, finded.version)) {
finded.hasUpdate = true;
finded.iconUrl = plg.iconUrl;
finded.url = plg.url;
if (finded.id === lastUsedPlugin?.id) {
setLastUsedPlugin(finded);
}
return false;
}
return true;
});
setMMKVObject(INSTALLED_PLUGINS, installedPlugins);
setMMKVObject(AVAILABLE_PLUGINS, fetchedPluginsMap);
filterPlugins(languagesFilter);
}
return false;
}
return true;
});
setMMKVObject(INSTALLED_PLUGINS, installedPlugins);
setMMKVObject(AVAILABLE_PLUGINS, fetchedPlugins);
filterPlugins(languagesFilter);
});
}, [languagesFilter]);
const toggleLanguageFilter = (lang: Language) => {
const toggleLanguageFilter = (lang: string) => {
const newFilter = languagesFilter.includes(lang)
? languagesFilter.filter(l => l !== lang)
: [...languagesFilter, lang];
: [lang, ...languagesFilter];
setLanguagesFilter(newFilter);
filterPlugins(newFilter);
};
@@ -101,25 +101,19 @@ export default function usePlugins() {
*/
const installPlugin = (plugin: PluginItem) => {
return _install(plugin.url).then(_plg => {
return _install(plugin).then(_plg => {
if (_plg) {
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS) || [];
const availablePlugins =
getMMKVObject<PluginsMap>(AVAILABLE_PLUGINS) || ({} as PluginsMap);
availablePlugins[plugin.lang] = availablePlugins[plugin.lang]?.filter(
plg => plg.id !== plugin.id,
);
const actualPlugin: PluginItem = {
...plugin,
version: _plg.version,
hasSettings: !!_plg.pluginSettings,
};
// safe
if (!installedPlugins.some(plg => plg.id === plugin.id)) {
setMMKVObject(INSTALLED_PLUGINS, [...installedPlugins, actualPlugin]);
}
setMMKVObject(AVAILABLE_PLUGINS, availablePlugins);
filterPlugins(languagesFilter);
} else {
throw new Error(
@@ -135,17 +129,6 @@ export default function usePlugins() {
}
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS) || [];
const availablePlugins =
getMMKVObject<PluginsMap>(AVAILABLE_PLUGINS) || ({} as PluginsMap);
// safe
if (!availablePlugins[plugin.lang]?.some(_plg => _plg.id === plugin.id)) {
availablePlugins[plugin.lang] = [
...(availablePlugins[plugin.lang] || []),
plugin,
];
setMMKVObject(AVAILABLE_PLUGINS, availablePlugins);
}
setMMKVObject(
INSTALLED_PLUGINS,
installedPlugins.filter(plg => plg.id !== plugin.id),
@@ -156,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) {
+33 -1
View File
@@ -4,6 +4,7 @@ import {
LibrarySortOrder,
} from '@screens/library/constants/constants';
import { useMMKVObject } from 'react-native-mmkv';
import { Voice } from 'expo-speech';
export const APP_SETTINGS = 'APP_SETTINGS';
export const BROWSE_SETTINGS = 'BROWSE_SETTINGS';
@@ -27,6 +28,7 @@ export interface AppSettings {
showUpdatesTab: boolean;
showLabelsInNav: boolean;
useFabForContinueReading: boolean;
disableLoadingAnimations: boolean;
/**
* Library settings
@@ -55,6 +57,7 @@ export interface AppSettings {
export interface BrowseSettings {
showMyAnimeList: boolean;
showAniList: boolean;
globalSearchConcurrency?: number;
}
export interface LibrarySettings {
@@ -71,7 +74,9 @@ export interface LibrarySettings {
}
export interface ChapterGeneralSettings {
keepScreenOn: boolean;
fullScreenMode: boolean;
pageReader: boolean;
swipeGestures: boolean;
showScrollPercentage: boolean;
useVolumeButtons: boolean;
@@ -81,6 +86,9 @@ export interface ChapterGeneralSettings {
autoScrollOffset: number | null;
verticalSeekbar: boolean;
removeExtraParagraphSpacing: boolean;
bionicReading: boolean;
tapToScroll: boolean;
TTSEnable: boolean;
}
export interface ReaderTheme {
@@ -99,6 +107,15 @@ export interface ChapterReaderSettings {
customCSS: string;
customJS: string;
customThemes: ReaderTheme[];
tts?: {
voice?: Voice;
rate?: number;
pitch?: number;
};
epubLocation: string;
epubUseAppTheme: boolean;
epubUseCustomCSS: boolean;
epubUseCustomJS: boolean;
}
const initialAppSettings: AppSettings = {
@@ -117,6 +134,7 @@ const initialAppSettings: AppSettings = {
showUpdatesTab: true,
showLabelsInNav: true,
useFabForContinueReading: false,
disableLoadingAnimations: false,
/**
* Library settings
@@ -145,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,
@@ -158,6 +179,9 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
autoScrollOffset: null,
verticalSeekbar: true,
removeExtraParagraphSpacing: false,
bionicReading: false,
tapToScroll: false,
TTSEnable: false,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
@@ -165,12 +189,20 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
textColor: '#CCCCCC',
textSize: 16,
textAlign: 'left',
padding: 2,
padding: 16,
fontFamily: '',
lineHeight: 1.5,
customCSS: '',
customJS: '',
customThemes: [],
tts: {
rate: 1,
pitch: 1,
},
epubLocation: '',
epubUseAppTheme: false,
epubUseCustomCSS: false,
epubUseCustomJS: false,
};
export const useAppSettings = () => {
+8
View File
@@ -0,0 +1,8 @@
import { SourceNovel } from '@plugins/types';
import { NativeModules } from 'react-native';
interface EpubUtilInterface {
parseNovelAndChapters: (epubDirPath: string) => Promise<SourceNovel>;
}
const { EpubUtil } = NativeModules;
export default EpubUtil as EpubUtilInterface;
+39
View File
@@ -0,0 +1,39 @@
import { NativeModules } from 'react-native';
interface ReadDirResult {
name: string;
path: string;
isDirectory: boolean; // int
}
interface FileManagerInterface {
writeFile: (path: string, content: string) => Promise<void>;
readFile: (path: string) => string;
resolveExternalContentUri: (uriString: string) => Promise<string | null>;
copyFile: (sourcePath: string, destPath: string) => Promise<void>;
moveFile: (sourcePath: string, destPath: string) => Promise<void>;
exists: (filePath: string) => Promise<boolean>;
/**
* @description create parents, and do nothing if exists;
*/
mkdir: (filePath: string) => Promise<void>;
/**
* @description remove recursively
*/
unlink: (filePath: string) => Promise<void>;
readDir: (dirPath: string) => Promise<ReadDirResult[]>;
pickFolder: () => Promise<string | null>;
downloadFile: (
url: string,
destPath: string,
method: string,
headers: Record<string, string> | Headers,
body?: string,
) => Promise<void>;
ExternalDirectoryPath: string;
ExternalCachesDirectoryPath: string;
}
const { FileManager } = NativeModules;
export default FileManager as FileManagerInterface;
-32
View File
@@ -1,32 +0,0 @@
import { NativeModules, Platform } from 'react-native';
const { NavigationBarColor } = NativeModules;
const changeNavigationBarColor = (
color = '',
light = false,
animated = true,
) => {
if (Platform.OS === 'android') {
const LightNav = light ? true : false;
NavigationBarColor.changeNavigationBarColor(color, LightNav, animated);
}
};
const hideNavigationBar = () => {
if (Platform.OS === 'android') {
NavigationBarColor.hideNavigationBar();
} else {
return false;
}
};
const showNavigationBar = () => {
if (Platform.OS === 'android') {
NavigationBarColor.showNavigationBar();
} else {
return false;
}
};
export { changeNavigationBarColor, hideNavigationBar, showNavigationBar };
-8
View File
@@ -1,8 +0,0 @@
import { NativeModules } from 'react-native';
interface ZipArchiveInterface {
writeFile: (path: string, content: string) => Promise<void>;
readFile: (path: string) => Promise<string>;
}
const { TextFile } = NativeModules;
export default TextFile as ZipArchiveInterface;
+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
+77 -26
View File
@@ -2,8 +2,12 @@ import React, { useEffect } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import * as Linking from 'expo-linking';
import { setBarColor } from '@theme/utils/setBarColor';
import {
changeNavigationBarColor,
setStatusBarColor,
} from '@theme/utils/setBarColor';
import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted';
import { useGithubUpdateChecker } from '@hooks/common/githubUpdateChecker';
@@ -11,44 +15,65 @@ 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 { updateLibrary } from '@services/updates';
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 { changeNavigationBarColor } from '@native/NavigationBarColor';
import Color from 'color';
import { useMMKVBoolean } from 'react-native-mmkv';
import OnboardingScreen from '@screens/onboarding/OnboardingScreen';
import {
createRepository,
isRepoUrlDuplicate,
} from '@database/queries/RepositoryQueries';
import { showToast } from '@utils/showToast';
import ServiceManager from '@services/ServiceManager';
const Stack = createStackNavigator<RootStackParamList>();
const MainNavigator = () => {
const url = Linking.useURL();
const theme = useTheme();
const { updateLibraryOnLaunch } = useAppSettings();
const { refreshPlugins } = usePlugins();
const [isOnboarded] = useMMKVBoolean('IS_ONBOARDED');
useEffect(() => {
const timer = setTimeout(async () => {
setBarColor(theme);
changeNavigationBarColor(
Color(theme.surface2).hex(),
!theme.isDark,
true,
);
setStatusBarColor(theme);
changeNavigationBarColor(Color(theme.surface2).hex(), theme.isDark);
}, 500);
return () => {
@@ -58,14 +83,40 @@ const MainNavigator = () => {
useEffect(() => {
if (updateLibraryOnLaunch) {
updateLibrary();
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' });
}
refreshPlugins();
}, []);
if (isOnboarded) {
// hack this helps app has enough time to initialize database;
refreshPlugins();
}
}, [isOnboarded]);
useEffect(() => {
if (url) {
const { hostname, path, queryParams } = Linking.parse(url);
if (hostname === 'repo' && path === 'add') {
const repoUrl = queryParams?.url;
if (typeof repoUrl === 'string') {
isRepoUrlDuplicate(repoUrl).then(isDuplicated => {
if (isDuplicated) {
showToast('A respository with this url already exists!');
} else {
createRepository(repoUrl);
}
});
}
}
}
}, [url]);
const { isNewVersion, latestRelease } = useGithubUpdateChecker();
if (!isOnboarded) {
return <OnboardingScreen />;
}
return (
// @ts-ignore
<NavigationContainer theme={{ colors: theme, dark: theme.isDark }}>
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
<Stack.Navigator screenOptions={{ headerShown: false }}>
+4 -2
View File
@@ -10,10 +10,11 @@ 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';
import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen';
// import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen';
import StatsScreen from '@screens/StatsScreen/StatsScreen';
import { MoreStackParamList, SettingsStackParamList } from './types';
@@ -33,6 +34,7 @@ const SettingsStack = () => (
<Stack.Screen name="BackupSettings" component={BackupSettings} />
<Stack.Screen name="AppearanceSettings" component={AppearanceSettings} />
<Stack.Screen name="AdvancedSettings" component={AdvancedSettings} />
<Stack.Screen name="RespositorySettings" component={RespositorySettings} />
{/* <Stack.Screen name="LibrarySettings" component={LibrarySettings} /> */}
</Stack.Navigator>
);
@@ -41,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} />
+10 -4
View File
@@ -30,7 +30,7 @@ export type RootStackParamList = {
WebviewScreen: {
name: string;
url: string;
pluginId?: string;
pluginId: string;
isNovel?: boolean;
};
};
@@ -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;
@@ -80,6 +85,7 @@ export type SettingsStackParamList = {
AppearanceSettings: undefined;
AdvancedSettings: undefined;
LibrarySettings: undefined;
RespositorySettings: undefined;
};
export type NovelScreenProps = StackScreenProps<RootStackParamList, 'Novel'>;
@@ -93,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
@@ -1,2 +0,0 @@
export const defaultCover =
'https://github.com/LNReader/lnreader-sources/blob/main/icons/src/coverNotAvailable.jpg?raw=true';
+2
View File
@@ -0,0 +1,2 @@
export const defaultCover =
'https://github.com/LNReader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
+104 -26
View File
@@ -1,4 +1,6 @@
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import FileManager from '@native/FileManager';
import { parse as parseProto } from 'protobufjs';
type FetchInit = {
headers?: Record<string, string> | Headers;
@@ -48,35 +50,19 @@ export const fetchApi = async (
const FILE_READER_PREFIX_LENGTH = 'data:application/octet-stream;base64,'
.length;
/**
*
* @param url
* @param init
* @returns base64 of file
*/
export const fetchFile = async (
export const downloadFile = async (
url: string,
destPath: string,
init?: FetchInit,
): Promise<string> => {
): Promise<void> => {
init = makeInit(init);
try {
const res = await fetch(url, init);
if (!res.ok) {
throw new Error();
}
const blob = await res.blob();
return await new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onloadend = () => {
resolve(fr.result.slice(FILE_READER_PREFIX_LENGTH) as string);
};
fr.onerror = () => reject();
fr.onabort = () => reject();
fr.readAsDataURL(blob);
});
} catch (e) {
return '';
}
return FileManager.downloadFile(
url,
destPath,
init.method || 'get',
init.headers as Record<string, string>,
init.body?.toString(),
);
};
/**
@@ -111,3 +97,95 @@ export const fetchText = async (
return '';
}
};
function base64ToBytesArr(str: string) {
const abc = [
...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',
]; // base64 alphabet
let result = [];
for (let i = 0; i < str.length / 4; i++) {
let chunk = [...str.slice(4 * i, 4 * i + 4)];
let bin = chunk
.map(x => abc.indexOf(x).toString(2).padStart(6, '0'))
.join('');
let bytes = bin.match(/.{1,8}/g)?.map(x => +('0b' + x)) || [];
result.push(
...bytes.slice(
0,
3 - Number(str[4 * i + 2] === '=') - Number(str[4 * i + 3] === '='),
),
);
}
return result;
}
interface ProtoRequestInit {
// merged .proto file
proto: string;
requestType: string;
requestData?: any;
responseType: string;
}
const BYTE_MARK = BigInt((1 << 8) - 1);
export const fetchProto = async function (
protoInit: ProtoRequestInit,
url: string,
init?: FetchInit,
) {
const protoRoot = parseProto(protoInit.proto).root;
const RequestMessge = protoRoot.lookupType(protoInit.requestType);
if (RequestMessge.verify(protoInit.requestData)) {
throw new Error('Invalid Proto');
}
// encode request data
const encodedrequest = RequestMessge.encode(protoInit.requestData).finish();
const requestLength = BigInt(encodedrequest.length);
const headers = new Uint8Array(
Array(5)
.fill(0)
.map((v, idx) => {
if (idx === 0) {
return 0;
}
return Number((requestLength >> BigInt(8 * (5 - idx - 1))) & BYTE_MARK);
}),
);
init = await makeInit(init);
const bodyArray = new Uint8Array(headers.length + encodedrequest.length);
bodyArray.set(headers, 0);
bodyArray.set(encodedrequest, headers.length);
return fetch(url, {
method: 'POST',
...init,
body: bodyArray,
} as RequestInit)
.then(r => r.blob())
.then(blob => {
// decode response data
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onloadend = () => {
const payload = new Uint8Array(
base64ToBytesArr(
fr.result.slice(FILE_READER_PREFIX_LENGTH) as string,
),
);
const length = Number(
BigInt(payload[1] << 24) |
BigInt(payload[2] << 16) |
BigInt(payload[3] << 8) |
BigInt(payload[4]),
);
const ResponseMessage = protoRoot.lookupType(protoInit.responseType);
resolve(ResponseMessage.decode(payload.slice(5, 5 + length)));
};
fr.onerror = () => reject();
fr.onabort = () => reject();
fr.readAsDataURL(blob);
});
});
};
-266
View File
@@ -1,266 +0,0 @@
const htmlToText = (html, options = {}) => {
const { removeLineBreaks = true } = options;
if (!html) {
return "Chapter is empty.\n\nReport if it's available in webview.";
}
html = html.trim();
let text = removeLineBreaks && html.replace(/(?:\n|\r\n|\r)/gi, '');
text = html
/**
* <br> -> \n
*/
.replace(/<\s*br[^>]*>/gi, '\n')
/**
* <hr> -> [hr]()
*/
.replace(/<\s*hr[^>]*>/gi, '')
/**
* <li> -> \n
*/
.replace(/<\s*\/li[^>]*>/gi, '\n')
/**
* <p> -> \n\n
*/
.replace(/<\s*p[^>]*>/gi, '\n\n')
/**
* Remove script and noscript tags
*/
.replace(/<\s*script[^>]*>[\s\S]*?<\/script>/gim, '')
.replace(/<\s*noscript[^>]*>[\s\S]*?<\/noscript>/gim, '')
/**
* Remove style tags
*/
.replace(/<\s*style[^>]*>[\s\S]*?<\/style>/gim, '')
/**
* Remove comments
*/
.replace(/<!--.*?-->/gim, '')
/**
* <a href='link'>Text</a> -> [Text](link)
*/
.replace(
/<\s*a[^>]*href=['"](http.*?)['"][^>]*>([\s\S]*?)<\/\s*a\s*>/gi,
'$2',
)
// .replace(
// /<\s*a[^>]*href=['"](http.*?)['"][^>]*>([\s\S]*?)<\/\s*a\s*>/gi,
// "[$2]($1)"
// )
/**
* <img> -> [img](src)
*/
.replace(
/<\s*img[^>]*[src|data-src]=['"](.*?)['"][^>]*>/gi,
'[Image]($1)\n',
)
/**
* Remove remaining tags
*/
.replace(/(<([^>-]+)>)/gi, '')
/**
* Remove tabs.
*/
.replace(/\t/g, '')
/**
* Remove newlines at the beginning of the text.
*/
.replace(/^\n+/m, '')
/**
* Replace multiple spaces with a single space.
*/
.replace(/ {2,}/g, ' ')
/**
* Make sure there are never more than two consecutive linebreaks.
*/
.replace(/\n{2,}/g, '\n\n')
/**
* Decode HTML entities.
*/
.replace(/&([^;]+);/g, decodeHtmlEntity)
.trim();
return text;
};
const decodeHtmlEntity = (m, n) => {
let code;
if (n.substr(0, 1) === '#') {
if (n.substr(1, 1) === 'x') {
code = parseInt(n.substr(2), 16);
} else {
code = parseInt(n.substr(1), 10);
}
} else {
code = ENTITIES_MAP[n];
}
return code === undefined || isNaN(code)
? '&' + n + ';'
: String.fromCharCode(code);
};
let ENTITIES_MAP = {
nbsp: 160,
iexcl: 161,
cent: 162,
pound: 163,
curren: 164,
yen: 165,
brvbar: 166,
sect: 167,
uml: 168,
copy: 169,
ordf: 170,
laquo: 171,
not: 172,
shy: 173,
reg: 174,
macr: 175,
deg: 176,
plusmn: 177,
sup2: 178,
sup3: 179,
acute: 180,
micro: 181,
para: 182,
middot: 183,
cedil: 184,
sup1: 185,
ordm: 186,
raquo: 187,
frac14: 188,
frac12: 189,
frac34: 190,
iquest: 191,
Agrave: 192,
Aacute: 193,
Acirc: 194,
Atilde: 195,
Auml: 196,
Aring: 197,
AElig: 198,
Ccedil: 199,
Egrave: 200,
Eacute: 201,
Ecirc: 202,
Euml: 203,
Igrave: 204,
Iacute: 205,
Icirc: 206,
Iuml: 207,
ETH: 208,
Ntilde: 209,
Ograve: 210,
Oacute: 211,
Ocirc: 212,
Otilde: 213,
Ouml: 214,
times: 215,
Oslash: 216,
Ugrave: 217,
Uacute: 218,
Ucirc: 219,
Uuml: 220,
Yacute: 221,
THORN: 222,
szlig: 223,
agrave: 224,
aacute: 225,
acirc: 226,
atilde: 227,
auml: 228,
aring: 229,
aelig: 230,
ccedil: 231,
egrave: 232,
eacute: 233,
ecirc: 234,
euml: 235,
igrave: 236,
iacute: 237,
icirc: 238,
iuml: 239,
eth: 240,
ntilde: 241,
ograve: 242,
oacute: 243,
ocirc: 244,
otilde: 245,
ouml: 246,
divide: 247,
oslash: 248,
ugrave: 249,
uacute: 250,
ucirc: 251,
uuml: 252,
yacute: 253,
thorn: 254,
yuml: 255,
quot: 34,
amp: 38,
lt: 60,
gt: 62,
OElig: 338,
oelig: 339,
Scaron: 352,
scaron: 353,
Yuml: 376,
circ: 710,
tilde: 732,
ensp: 8194,
emsp: 8195,
thinsp: 8201,
zwnj: 8204,
zwj: 8205,
lrm: 8206,
rlm: 8207,
ndash: 8211,
mdash: 8212,
lsquo: 8216,
rsquo: 8217,
sbquo: 8218,
ldquo: 8220,
rdquo: 8221,
bdquo: 8222,
dagger: 8224,
Dagger: 8225,
permil: 8240,
lsaquo: 8249,
rsaquo: 8250,
euro: 8364,
apos: 39,
};
export { htmlToText };
+123
View File
@@ -0,0 +1,123 @@
import { MMKV } from 'react-native-mmkv';
const store = new MMKV({ id: 'plugin_db' });
const PLUGIN_STORAGE = '_DB_';
const WEBVIEW_LOCAL_STORAGE = '_LocalStorage';
const WEBVIEW_SESSION_STORAGE = '_SessionStorage';
interface StoredItem {
created: Date;
value: any;
expires?: number; // timestamp (miliseconds)
}
class Storage {
#pluginID: string;
constructor(pluginID: string) {
this.#pluginID = pluginID;
}
/**
* Sets a key-value pair in storage.
*
* @param {string} key - The key to set.
* @param {any} value - The value to set.
* @param {Date | number} [expires] - Optional expiry date or time in milliseconds.
*/
set(key: string, value: any, expires?: Date | number): void {
const item: StoredItem = {
created: new Date(),
value,
expires: expires instanceof Date ? expires.getTime() : expires,
};
store.set(this.#pluginID + PLUGIN_STORAGE + key, JSON.stringify(item));
}
/**
* Retrieves the value for a given key from storage.
*
* @param {string} key - The key to retrieve the value for.
* @param {boolean} [raw] - Optional flag to return the raw stored item.
* @returns {any} The stored value or undefined if key is not found.
*/
get(key: string, raw?: boolean): any {
const storedItem = store.getString(this.#pluginID + PLUGIN_STORAGE + key);
if (storedItem) {
const item: StoredItem = JSON.parse(storedItem);
if (item.expires) {
if (Date.now() > item.expires) {
this.delete(key);
return undefined;
}
if (raw) {
item.expires = new Date(item.expires).getTime();
}
}
return raw ? item : item.value;
}
return undefined;
}
/**
* Deletes a key from storage.
*
* @param {string} key - The key to delete.
*/
delete(key: string): void {
store.delete(this.#pluginID + PLUGIN_STORAGE + key);
}
/**
* Clears all stored items from storage.
*/
clearAll(): void {
const keysToRemove = this.getAllKeys();
keysToRemove.forEach(key => this.delete(key));
}
/**
* Retrieves all keys set by the `set` method.
*
* @returns {string[]} An array of keys.
*/
getAllKeys(): string[] {
const keys = store
.getAllKeys()
.filter(key => key.startsWith(this.#pluginID + PLUGIN_STORAGE))
.map(key => key.replace(this.#pluginID + PLUGIN_STORAGE, ''));
return keys;
}
}
class LocalStorage {
#pluginID: string;
constructor(pluginID: string) {
this.#pluginID = pluginID;
}
get(): StoredItem['value'] | undefined {
const data = store.getString(this.#pluginID + WEBVIEW_LOCAL_STORAGE);
return data ? JSON.parse(data) : undefined;
}
}
class SessionStorage {
#pluginID: string;
constructor(pluginID: string) {
this.#pluginID = pluginID;
}
get(): StoredItem['value'] | undefined {
const data = store.getString(this.#pluginID + WEBVIEW_SESSION_STORAGE);
return data ? JSON.parse(data) : undefined;
}
}
export { Storage, LocalStorage, SessionStorage };
//to record data from the web view
export { WEBVIEW_LOCAL_STORAGE, WEBVIEW_SESSION_STORAGE, store };
+94 -84
View File
@@ -1,40 +1,47 @@
import RNFS from 'react-native-fs';
import { PluginDownloadFolder } from '@utils/constants/download';
import { reverse, uniqBy } from 'lodash-es';
import { newer } from '@utils/compareVersion';
import { Language } from '@utils/constants/languages';
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';
import { fetchApi, fetchFile, fetchText } from './helpers/fetch';
import { downloadFile, fetchApi, fetchProto, fetchText } from './helpers/fetch';
import { defaultCover } from './helpers/constants';
import { Storage, LocalStorage, SessionStorage } from './helpers/storage';
import { encode, decode } from 'urlencode';
import TextFile from '@native/TextFile';
const pluginsFilePath = PluginDownloadFolder + '/plugins.json';
import { Parser } from 'htmlparser2';
import FileManager from '@native/FileManager';
import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries';
import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
const packages: Record<string, any> = {
'htmlparser2': { Parser },
'cheerio': { load },
'dayjs': dayjs,
'qs': qs,
'urlencode': { encode, decode },
'@libs/novelStatus': { NovelStatus },
'@libs/fetch': { fetchApi, fetchFile, fetchText },
'@libs/fetch': { fetchApi, fetchText, fetchProto },
'@libs/isAbsoluteUrl': { isUrlAbsolute },
'@libs/filterInputs': { FilterTypes },
'@libs/defaultCover': { defaultCover },
};
const _require = (packageName: string) => {
return packages[packageName];
};
const initPlugin = (rawCode: string) => {
const initPlugin = (pluginId: string, rawCode: string) => {
try {
const _require = (packageName: string) => {
if (packageName === '@libs/storage') {
return {
storage: new Storage(pluginId),
localStorage: new LocalStorage(pluginId),
sessionStorage: new SessionStorage(pluginId),
};
}
return packages[packageName];
};
/* eslint no-new-func: "off", curly: "error" */
const plugin: Plugin = Function(
'require',
@@ -50,67 +57,42 @@ const initPlugin = (rawCode: string) => {
};
const plugins: Record<string, Plugin | undefined> = {};
let serializedPlugins: Record<string, string | undefined>;
const serializePlugin = async (
pluginId: string,
rawCode: string,
installed: boolean,
) => {
if (!serializedPlugins) {
if (await RNFS.exists(pluginsFilePath)) {
const content = await TextFile.readFile(pluginsFilePath);
serializedPlugins = JSON.parse(content);
} else {
serializedPlugins = {};
}
}
if (installed) {
serializedPlugins[pluginId] = rawCode;
} else {
serializedPlugins[pluginId] = undefined;
}
if (!(await RNFS.exists(PluginDownloadFolder))) {
await RNFS.mkdir(PluginDownloadFolder);
}
await TextFile.writeFile(pluginsFilePath, JSON.stringify(serializedPlugins));
};
const deserializePlugins = () => {
return TextFile.readFile(pluginsFilePath)
.then(content => {
serializedPlugins = JSON.parse(content);
for (const script of Object.values(serializedPlugins)) {
const plugin = initPlugin(script as string);
if (plugin) {
plugins[plugin.id] = plugin;
}
}
})
.catch(() => {
// nothing to read
});
};
const installPlugin = async (url: string): Promise<Plugin | undefined> => {
const installPlugin = async (
_plugin: PluginItem,
): Promise<Plugin | undefined> => {
try {
return await fetch(url, {
const rawCode = await fetch(_plugin.url, {
headers: { 'pragma': 'no-cache', 'cache-control': 'no-cache' },
})
.then(res => res.text())
.then(async rawCode => {
const plugin = initPlugin(rawCode);
if (!plugin) {
return undefined;
}
let currentPlugin = plugins[plugin.id];
if (!currentPlugin || newer(plugin.version, currentPlugin.version)) {
plugins[plugin.id] = plugin;
currentPlugin = plugin;
await serializePlugin(plugin.id, rawCode, true);
}
return currentPlugin;
});
}).then(res => res.text());
const plugin = initPlugin(_plugin.id, rawCode);
if (!plugin) {
return undefined;
}
let currentPlugin = plugins[plugin.id];
if (!currentPlugin || newer(plugin.version, currentPlugin.version)) {
plugins[plugin.id] = plugin;
currentPlugin = plugin;
// save plugin code;
const pluginDir = `${PLUGIN_STORAGE}/${plugin.id}`;
await FileManager.mkdir(pluginDir);
const pluginPath = pluginDir + '/index.js';
const customJSPath = pluginDir + '/custom.js';
const customCSSPath = pluginDir + '/custom.css';
if (_plugin.customJS) {
await downloadFile(_plugin.customJS, customJSPath);
} else if (await FileManager.exists(customJSPath)) {
FileManager.unlink(customJSPath);
}
if (_plugin.customCSS) {
await downloadFile(_plugin.customCSS, customCSSPath);
} else if (await FileManager.exists(customCSSPath)) {
FileManager.unlink(customCSSPath);
}
await FileManager.writeFile(pluginPath, rawCode);
}
return currentPlugin;
} catch (e: any) {
throw e;
}
@@ -118,24 +100,53 @@ const installPlugin = async (url: string): Promise<Plugin | undefined> => {
const uninstallPlugin = async (_plugin: PluginItem) => {
plugins[_plugin.id] = undefined;
return serializePlugin(_plugin.id, '', false);
store.getAllKeys().forEach(key => {
if (key.startsWith(_plugin.id)) {
store.delete(key);
}
});
const pluginFilePath = `${PLUGIN_STORAGE}/${_plugin.id}/index.js`;
if (await FileManager.exists(pluginFilePath)) {
await FileManager.unlink(pluginFilePath);
}
};
const updatePlugin = async (plugin: PluginItem) => {
return installPlugin(plugin.url);
return installPlugin(plugin);
};
const fetchPlugins = (): Promise<Record<Language, Array<PluginItem>>> => {
// plugins host
const githubUsername = 'LNReader';
const githubRepository = 'lnreader-sources';
const fetchPlugins = async (): Promise<PluginItem[]> => {
const allPlugins: PluginItem[] = [];
const allRepositories = await getRepositoriesFromDb();
return fetch(
`https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/dist/.dist/plugins.min.json`,
).then(res => res.json());
const repoPluginsRes = await Promise.allSettled(
allRepositories.map(({ url }) => fetch(url).then(res => res.json())),
);
repoPluginsRes.forEach(repoPlugins => {
if (repoPlugins.status === 'fulfilled') {
allPlugins.push(...repoPlugins.value);
} else {
showToast(repoPlugins.reason.toString());
}
});
return uniqBy(reverse(allPlugins), 'id');
};
const getPlugin = (pluginId: string) => plugins[pluginId];
const getPlugin = (pluginId: string) => {
if (!plugins[pluginId]) {
const filePath = `${PLUGIN_STORAGE}/${pluginId}/index.js`;
try {
const code = FileManager.readFile(filePath);
const plugin = initPlugin(pluginId, code);
plugins[pluginId] = plugin;
} catch {
// file doesnt exist
}
}
return plugins[pluginId];
};
const LOCAL_PLUGIN_ID = 'local';
@@ -144,7 +155,6 @@ export {
installPlugin,
uninstallPlugin,
updatePlugin,
deserializePlugins,
fetchPlugins,
LOCAL_PLUGIN_ID,
};
+7 -7
View File
@@ -43,13 +43,13 @@ type ExcludableCheckboxFilter = {
export type Filters = Record<
string,
| { label: string } & (
| PickerFilter
| CheckboxFilter
| TextFilter
| SwitchFilter
| ExcludableCheckboxFilter
)
{ label: string } & (
| PickerFilter
| CheckboxFilter
| TextFilter
| SwitchFilter
| ExcludableCheckboxFilter
)
>;
export type FilterToValues<
+15 -4
View File
@@ -1,7 +1,7 @@
import { FilterToValues, Filters } from './filterTypes';
import { Language } from '@utils/constants/languages';
export interface NovelItem {
id: undefined;
name: string;
path: string;
cover?: string;
@@ -37,7 +37,6 @@ export interface SourceNovel extends NovelItem {
export interface SourcePage {
chapters: ChapterItem[];
latestChapter?: ChapterItem;
}
export interface PopularNovelsOptions<Q extends Filters> {
@@ -50,15 +49,27 @@ export interface PluginItem {
id: string;
name: string;
site: string;
lang: Language;
lang: string;
version: string;
url: string; // the url of raw code
iconUrl: string;
customJS?: string;
customCSS?: string;
hasUpdate?: boolean;
hasSettings?: boolean;
}
export interface ImageRequestInit {
[x: string]: string | Record<string, string> | Headers | FormData | undefined;
method?: string;
headers?: Record<string, string>;
body?: string;
}
export interface Plugin extends PluginItem {
imageRequestInit?: ImageRequestInit;
filters?: Filters;
pluginSettings: any;
popularNovels: (
pageNo: number,
options?: PopularNovelsOptions<Filters>,
@@ -67,6 +78,6 @@ export interface Plugin extends PluginItem {
parsePage?: (novelPath: string, page: string) => Promise<SourcePage>;
parseChapter: (chapterPath: string) => Promise<string>;
searchNovels: (searchTerm: string, pageNo: number) => Promise<NovelItem[]>;
fetchImage: (url: string) => Promise<string>;
resolveUrl?: (path: string, isNovel?: boolean) => string;
webStorageUtilized?: boolean;
}
@@ -16,7 +16,7 @@ import { getString } from '@strings/translations';
import { StyleSheet } from 'react-native';
import { useLibraryNovels } from '@screens/library/hooks/useLibrary';
import { switchNovelToLibrary } from '@database/queries/NovelQueries';
import { NovelInfo } from '@database/types';
import { LibraryNovelInfo, NovelInfo } from '@database/types';
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrowseSourceScreenProps } from '@navigators/types';
@@ -46,7 +46,6 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
clearSearchResults,
searchError,
} = useSearchSource(pluginId);
const novelList = searchResults.length > 0 ? searchResults : novels;
const errorMessage = error || searchError;
@@ -64,6 +63,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
navigation.navigate('WebviewScreen', {
name: pluginName,
url: site,
pluginId,
});
};
@@ -75,7 +75,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
);
const navigateToNovel = useCallback(
(item: NovelItem) =>
(item: NovelItem | LibraryNovelInfo) =>
navigation.navigate('Novel', {
...item,
pluginId: pluginId,
@@ -120,6 +120,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
) : (
<NovelList
data={novelList}
inSource
renderItem={({ item }) => {
const inLibrary = novelInLibrary(item.path);
@@ -130,6 +131,10 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
libraryStatus={inLibrary}
onPress={() => navigateToNovel(item)}
isSelected={false}
addSkeletonLoading={
(hasNextPage && !searchText) ||
(hasNextSearchPage && Boolean(searchText))
}
onLongPress={async () => {
await switchNovelToLibrary(item.path, pluginId);
setLibrary(prevValues => {
@@ -163,15 +168,9 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
fetchNextPage();
}
}}
ListFooterComponent={
(hasNextPage && !searchText) ||
(hasNextSearchPage && searchText) ? (
<SourceScreenSkeletonLoading theme={theme} />
) : undefined
}
onEndReachedThreshold={1.5}
/>
)}
{!showLatestNovels && filterValues && !searchText ? (
<>
<FAB
@@ -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 -1
View File
@@ -18,6 +18,7 @@ import { Category } from '@database/types';
import CategoryCard from './components/CategoryCard';
import { orderBy } from 'lodash-es';
import CategorySkeletonLoading from './components/CategorySkeletonLoading';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const CategoriesScreen = () => {
const theme = useTheme();
@@ -25,6 +26,7 @@ const CategoriesScreen = () => {
const [isLoading, setIsLoading] = useState(true);
const [categories, setCategories] = useState<Category[]>();
const { bottom } = useSafeAreaInsets();
const getCategories = async () => {
try {
@@ -105,7 +107,7 @@ const CategoriesScreen = () => {
/>
)}
<FAB
style={[styles.fab, { backgroundColor: theme.primary }]}
style={[styles.fab, { backgroundColor: theme.primary, bottom: bottom }]}
color={theme.onPrimary}
label={getString('common.add')}
uppercase={false}
@@ -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,62 +55,92 @@ 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) {
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,
novels: [],
isLoading: false,
error: error.message,
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(() => {
@@ -99,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);
}
+64 -9
View File
@@ -1,17 +1,32 @@
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';
import { useTheme } from '@hooks/persisted';
import { WebviewScreenProps } from '@navigators/types';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { resolveUrl } from '@services/plugin/fetch';
import {
WEBVIEW_LOCAL_STORAGE,
WEBVIEW_SESSION_STORAGE,
store,
} from '@plugins/helpers/storage';
import Appbar from './components/Appbar';
import Menu from './components/Menu';
type StorageData = {
localStorage?: Record<string, string>;
sessionStorage?: Record<string, string>;
};
const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => {
const { name, url, pluginId, isNovel } = route.params;
const uri = pluginId ? resolveUrl(pluginId, url, isNovel) : url;
const isSave = getPlugin(pluginId)?.webStorageUtilized;
const uri = resolveUrl(pluginId, url, isNovel);
const { bottom } = useSafeAreaInsets();
const theme = useTheme();
const webViewRef = useRef<WebView | null>(null);
@@ -21,31 +36,60 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => {
const [currentUrl, setCurrentUrl] = useState(uri);
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const [tempData, setTempData] = useState<StorageData>();
const [menuVisible, setMenuVisible] = useState(false);
const handleNavigation = (e: WebViewNavigation) => {
if (!e.loading) {
setTitle(e.title);
}
setCurrentUrl(e.url);
setCanGoBack(e.canGoBack);
setCanGoForward(e.canGoForward);
};
const saveData = () => {
if (pluginId && tempData && isSave) {
store.set(
pluginId + WEBVIEW_LOCAL_STORAGE,
JSON.stringify(tempData?.localStorage || {}),
);
store.set(
pluginId + WEBVIEW_SESSION_STORAGE,
JSON.stringify(tempData?.sessionStorage || {}),
);
}
};
useBackHandler(() => {
if (menuVisible) {
setMenuVisible(false);
return true;
}
if (canGoBack) {
webViewRef.current?.goBack();
return true;
}
saveData();
return false;
});
const injectJavaScriptCode =
'window.ReactNativeWebView.postMessage(JSON.stringify({localStorage, sessionStorage}))';
return (
<>
<Appbar
title={title}
theme={theme}
currentUrl={currentUrl}
canGoBack={canGoBack}
canGoForward={canGoForward}
webView={webViewRef}
navigation={navigation}
setMenuVisible={setMenuVisible}
goBack={() => {
saveData();
navigation.goBack();
}}
/>
<ProgressBar
color={theme.primary}
@@ -56,14 +100,25 @@ const WebviewScreen = ({ route, navigation }: WebviewScreenProps) => {
userAgent={getUserAgent()}
ref={webViewRef}
source={{ uri }}
onLoadProgress={({ nativeEvent }) => {
setProgress(nativeEvent.progress);
}}
onLoadEnd={({ nativeEvent }) => {
setTitle(nativeEvent.title);
}}
setDisplayZoomControls={true}
setBuiltInZoomControls={false}
setSupportMultipleWindows={false}
injectedJavaScript={injectJavaScriptCode}
onNavigationStateChange={handleNavigation}
onLoadProgress={({ nativeEvent }) => setProgress(nativeEvent.progress)}
onMessage={({ nativeEvent }) =>
setTempData(JSON.parse(nativeEvent.data))
}
containerStyle={{ paddingBottom: bottom }}
/>
{menuVisible ? (
<Menu
theme={theme}
currentUrl={currentUrl}
webView={webViewRef}
setMenuVisible={setMenuVisible}
/>
) : null}
</>
);
};
+57 -92
View File
@@ -1,137 +1,102 @@
import React, { useState } from 'react';
import { Share, View, Text } from 'react-native';
import React, { RefObject } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { IconButton, Menu } from 'react-native-paper';
import WebView from 'react-native-webview';
import * as Linking from 'expo-linking';
import { WebviewScreenProps } from '@navigators/types';
import { getString } from '@strings/translations';
import { IconButtonV2 } from '@components';
import { ThemeColors } from '@theme/types';
import { showToast } from '@utils/showToast';
import WebView from 'react-native-webview';
interface AppbarProps {
title: string;
theme: ThemeColors;
currentUrl: string;
canGoBack: boolean;
canGoForward: boolean;
webView: RefObject<WebView>;
navigation: WebviewScreenProps['navigation'];
setMenuVisible: (value: boolean) => void;
goBack: () => void;
}
const Appbar: React.FC<AppbarProps> = ({
title,
theme,
currentUrl,
canGoBack,
canGoForward,
webView,
navigation,
setMenuVisible,
goBack,
}) => {
const { top } = useSafeAreaInsets();
const [menuVisible, setMenuVisible] = useState(false);
return (
<View
style={{
paddingTop: top,
backgroundColor: theme.surface,
flexDirection: 'row',
}}
style={[
styles.container,
{ paddingTop: top, backgroundColor: theme.surface },
]}
>
<IconButton
icon="close"
iconColor={theme.onSurface}
onPress={() => navigation.goBack()}
theme={{ colors: { ...theme } }}
<IconButtonV2
name="close"
color={theme.onSurface}
onPress={goBack}
theme={theme}
/>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View style={styles.titleContainer}>
<Text
style={{
color: theme.onSurface,
textAlign: 'center',
}}
numberOfLines={1}
ellipsizeMode="tail"
style={[styles.title, { color: theme.onSurface }]}
>
{title}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<IconButton
icon="arrow-left"
iconColor={theme.onSurface}
<View style={styles.iconContainer}>
<IconButtonV2
name="arrow-left"
color={theme.onSurface}
disabled={!canGoBack}
onPress={() => webView.current?.goBack()}
theme={{ colors: { ...theme } }}
theme={theme}
/>
<IconButton
icon="arrow-right"
iconColor={theme.onSurface}
<IconButtonV2
name="arrow-right"
color={theme.onSurface}
disabled={!canGoForward}
onPress={() => webView.current?.goForward()}
theme={{ colors: { ...theme } }}
theme={theme}
/>
<Menu
visible={menuVisible}
onDismiss={() => setMenuVisible(false)}
anchor={
<IconButton
icon="dots-vertical"
iconColor={theme.onSurface}
onPress={() => setMenuVisible(true)}
theme={{ colors: { ...theme } }}
/>
}
style={{ backgroundColor: theme.surface2 }}
contentStyle={{ backgroundColor: theme.surface2 }}
>
<Menu.Item
title={getString('webview.refresh')}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
setMenuVisible(false);
webView.current?.reload();
}}
/>
<Menu.Item
title={getString('webview.share')}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
setMenuVisible(false);
Share.share({ message: currentUrl });
}}
/>
<Menu.Item
title={getString('webview.openInBrowser')}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
setMenuVisible(false);
Linking.openURL(currentUrl);
}}
/>
<Menu.Item
title={getString('webview.clearData')}
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
setMenuVisible(false);
webView.current?.clearCache?.(true);
webView.current?.reload();
showToast(getString('webview.dataDeleted'));
}}
/>
</Menu>
<IconButtonV2
name="dots-vertical"
color={theme.onSurface}
onPress={() => setMenuVisible(true)}
theme={theme}
/>
</View>
</View>
);
};
export default Appbar;
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
},
titleContainer: {
flex: 1,
justifyContent: 'center',
},
title: {
paddingBottom: 2,
paddingLeft: 2,
fontSize: 18,
},
url: {
fontSize: 16,
},
iconContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
});
@@ -0,0 +1,102 @@
import React, { RefObject } from 'react';
import { Pressable, Share, View, Text, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';
import * as Linking from 'expo-linking';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import { showToast } from '@utils/showToast';
interface MenuProps {
theme: ThemeColors;
currentUrl: string;
webView: RefObject<WebView>;
setMenuVisible: (value: boolean) => void;
}
const Menu: React.FC<MenuProps> = ({
theme,
currentUrl,
webView,
setMenuVisible,
}) => {
const { top } = useSafeAreaInsets();
return (
<Pressable onPress={() => setMenuVisible(false)} style={styles.container}>
<View style={[styles.menuContainer, { top }]}>
<Pressable
style={[styles.menuButton, { backgroundColor: theme.surface2 }]}
onPress={() => {
setMenuVisible(false);
webView.current?.reload();
}}
>
<Text style={{ color: theme.onSurface }}>
{getString('webview.refresh')}
</Text>
</Pressable>
<Pressable
style={[styles.menuButton, { backgroundColor: theme.surface2 }]}
onPress={() => {
setMenuVisible(false);
Share.share({ message: currentUrl });
}}
>
<Text style={{ color: theme.onSurface }}>
{getString('webview.share')}
</Text>
</Pressable>
<Pressable
style={[styles.menuButton, { backgroundColor: theme.surface2 }]}
onPress={() => {
setMenuVisible(false);
Linking.openURL(currentUrl);
}}
>
<Text style={{ color: theme.onSurface }}>
{getString('webview.openInBrowser')}
</Text>
</Pressable>
<Pressable
style={[styles.menuButton, { backgroundColor: theme.surface2 }]}
onPress={() => {
setMenuVisible(false);
webView.current?.clearCache?.(true);
webView.current?.reload();
showToast(getString('webview.dataDeleted'));
}}
>
<Text style={{ color: theme.onSurface }}>
{getString('webview.clearData')}
</Text>
</Pressable>
</View>
</Pressable>
);
};
export default Menu;
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
justifyContent: 'center',
alignItems: 'center',
},
menuContainer: {
position: 'absolute',
right: 0,
},
menuButton: {
padding: 15,
},
});
+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 (
<>
+214 -140
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 {
@@ -19,10 +18,9 @@ import {
import { coverPlaceholderColor } from '@theme/colors';
import { ThemeColors } from '@theme/types';
import { Swipeable } from 'react-native-gesture-handler';
import { languages } from '@utils/constants/languages';
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, {
@@ -30,6 +28,10 @@ import Animated, {
useSharedValue,
withTiming,
} from 'react-native-reanimated';
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
@@ -87,22 +102,6 @@ export const InstalledTab = memo(
{ backgroundColor: theme.inverseSurface },
]}
>
<IconButtonV2
name="update"
size={22}
color={theme.inverseOnSurface}
onPress={() => {
ref.close();
updatePlugin(item)
.then(version =>
showToast(
getString('browseScreen.updatedTo', { version }),
),
)
.catch((error: Error) => showToast(error.message));
}}
theme={theme}
/>
<IconButtonV2
name="earth"
size={22}
@@ -148,19 +147,15 @@ export const InstalledTab = memo(
android_ripple={{ color: theme.rippleColor }}
onPress={() => navigateToSource(item)}
>
<View style={{ flexDirection: 'row' }}>
<Image source={{ uri: item.iconUrl }} style={styles.icon} />
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Image
source={{ uri: item.iconUrl }}
style={[styles.icon, { backgroundColor: theme.surface }]}
/>
<View style={styles.details}>
<Text
numberOfLines={1}
style={[
{
color: item.hasUpdate
? theme.onSurfaceDisabled
: theme.onSurface,
},
styles.name,
]}
style={[{ color: theme.onSurface }, styles.name]}
>
{item.name}
</Text>
@@ -168,10 +163,40 @@ export const InstalledTab = memo(
numberOfLines={1}
style={[{ color: theme.onSurfaceVariant }, styles.addition]}
>
{`${languages[item.lang]} - ${item.version}`}
{`${item.lang} - ${item.version}`}
</Text>
</View>
</View>
<View style={{ flex: 1 }} />
{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}
color={theme.primary}
onPress={() => {
updatePlugin(item)
.then(version =>
showToast(
getString('browseScreen.updatedTo', { version }),
),
)
.catch((error: Error) => showToast(error.message));
}}
theme={theme}
/>
) : null}
<Button
title={getString('browseScreen.latest')}
textColor={theme.primary}
@@ -238,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>
</>
}
/>
@@ -246,7 +282,7 @@ export const InstalledTab = memo(
);
interface AvailablePluginCardProps {
plugin: PluginItem;
plugin: PluginItem & { header: boolean };
theme: ThemeColors;
installPlugin: (plugin: PluginItem) => Promise<void>;
}
@@ -268,114 +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,
]}
>
{`${languages[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"
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 = [];
for (const language of languagesFilter) {
if (filteredAvailablePlugins[language]) {
let plugins;
if (searchText) {
plugins = filteredAvailablePlugins[language]?.filter(plg =>
plg.name.toLocaleLowerCase().includes(searchText.toLowerCase()),
);
} else {
plugins = filteredAvailablePlugins[language];
}
if (plugins && plugins.length) {
list.push({
header: language,
data: plugins,
});
}
}
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
@@ -392,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>
)
}
/>
);
@@ -440,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 (

Some files were not shown because too many files have changed in this diff Show More