Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb3875fe2 | |||
| 2c9df1d93f | |||
| 5db79a09eb | |||
| ff771fdd68 | |||
| 169c4b9de1 | |||
| 4bcd22b9de | |||
| 8a2dc15da6 | |||
| 3f3a407820 | |||
| c72808d72f | |||
| dabe4710bb | |||
| cdc4ddcc97 | |||
| 8f783fd4c2 | |||
| 048119e530 | |||
| b68d44d500 | |||
| 4bc3638b38 | |||
| bc5e2f1fd1 | |||
| e8e7519c40 | |||
| 5460047e41 | |||
| bdd00cd87d | |||
| f83c017e09 | |||
| e38cb9972f | |||
| ac9c789a89 | |||
| 445187cf11 | |||
| 52ec11a33d | |||
| c498d7e9cd | |||
| 0fad9b907f | |||
| 0a170f14bc | |||
| 8245e77672 | |||
| 9c68260592 | |||
| b1778c5802 | |||
| be4ed6de79 | |||
| 3597b542d3 | |||
| fd6d912391 | |||
| d9dc18da43 | |||
| a22d754f95 | |||
| d9d7d3aaf0 | |||
| a399927264 | |||
| b4b00a5c66 | |||
| 9861f81589 | |||
| d06aecb5f8 | |||
| 86c82c1a61 | |||
| 1131446ddc | |||
| f6b2516643 | |||
| 7cf8692e77 | |||
| d6290294f7 | |||
| d5cb0a234f | |||
| 306d8d1d42 | |||
| 7b1eb7dd9d | |||
| 1ad2c98473 | |||
| f0b8df93b4 | |||
| 19ce7da32e | |||
| 9d35f6d303 | |||
| 31d0aa1787 | |||
| ea5fde4156 | |||
| 03e3816ab8 | |||
| 9d6e407c40 | |||
| 6781b5d6a4 | |||
| 352913f00e | |||
| 73f7ebf7b5 | |||
| 045d6419d8 | |||
| acc9d8e7ed | |||
| a29cce9eba | |||
| 1292f520bb | |||
| 8bd6131862 | |||
| ed6c2b7ba2 | |||
| f68a7a4e6a | |||
| c9d2ce382f | |||
| 5262567d61 | |||
| b45a30f0cc | |||
| 867b63af22 | |||
| b3a5e237f4 | |||
| 7aae3a81c3 | |||
| 078796784b | |||
| 15aed61aa6 | |||
| b67d6f8088 | |||
| 9b4bdfea89 | |||
| 70220a5a87 | |||
| aa95743d1c | |||
| eb818ef55b | |||
| 0bda5fd055 | |||
| ea336902f2 | |||
| 58a633e5b9 | |||
| 74ed5a446d | |||
| a5d073a1ec | |||
| f4d3d9d4eb | |||
| 772a9ef3a2 | |||
| 6f3278f6ff | |||
| f11a10ba22 | |||
| 02304fd394 | |||
| 94d11a12ff | |||
| fca37868f1 | |||
| 574f54a9bc | |||
| fb0649f07f | |||
| e3def3821e | |||
| 334c981013 | |||
| b62c8e3aea | |||
| 09a482e4b7 | |||
| b7bbbbe962 | |||
| 30265471b5 | |||
| b4b038dd42 | |||
| 6517028cd4 | |||
| 3f62f6f225 | |||
| 822256229f | |||
| 4f23e1814e | |||
| aec6c34b66 | |||
| e50b6c4a47 | |||
| 1ac6d116a2 | |||
| 6864d07dd9 | |||
| 671e4f4bfd | |||
| e2d22a5b05 | |||
| 3cb2eb6b11 | |||
| 4597a95e9c | |||
| cf4d97e7f9 | |||
| 09f7319f2f | |||
| c77dc52f29 | |||
| 3cd5b40c25 | |||
| 42570d29f3 | |||
| 7e7a8866ca | |||
| ed5e599edb | |||
| addcadeba2 | |||
| 484c2b7e33 | |||
| b6fea9111c | |||
| 9ef8cab475 | |||
| 7ea8ebacd1 | |||
| e171dfebc1 | |||
| 9f6ffc23bf | |||
| 8906253527 | |||
| 817e4dc9d1 |
@@ -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
@@ -74,4 +74,6 @@ web-build/
|
||||
# @end expo-cli
|
||||
|
||||
.vscode/
|
||||
src/sources/en/generators/
|
||||
src/sources/en/generators/
|
||||
*.lockb
|
||||
.tool-versions
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
**/node_modules
|
||||
**/node_modules
|
||||
android/**/js/van.js
|
||||
android/**/js/text-vibe.js
|
||||
|
||||
@@ -7,4 +7,5 @@ module.exports = {
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
quoteProps: 'preserve',
|
||||
endOfLine: 'auto', // stop prettier from getting mad on windows
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { enableFreeze } from 'react-native-screens';
|
||||
|
||||
enableFreeze(true);
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,471 @@
|
||||
window.reader = new (function () {
|
||||
const {
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval,
|
||||
DEBUG,
|
||||
strings,
|
||||
} = initialReaderConfig;
|
||||
|
||||
// state
|
||||
this.hidden = van.state(true);
|
||||
this.batteryLevel = van.state(batteryLevel);
|
||||
this.readerSettings = van.state(readerSettings);
|
||||
this.generalSettings = van.state(chapterGeneralSettings);
|
||||
|
||||
this.chapterElement = document.querySelector('#LNReader-chapter');
|
||||
this.selection = window.getSelection();
|
||||
this.viewport = document.querySelector('meta[name=viewport]');
|
||||
|
||||
this.novel = novel;
|
||||
this.chapter = chapter;
|
||||
this.nextChapter = nextChapter;
|
||||
this.strings = strings;
|
||||
this.autoSaveInterval = autoSaveInterval;
|
||||
this.rawHTML = this.chapterElement.innerHTML;
|
||||
|
||||
//layout props
|
||||
this.paddingTop = parseInt(
|
||||
getComputedStyle(document.querySelector('body')).getPropertyValue(
|
||||
'padding-top',
|
||||
),
|
||||
10,
|
||||
);
|
||||
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
||||
this.layoutHeight = window.screen.height;
|
||||
this.layoutWidth = window.screen.width;
|
||||
|
||||
this.post = obj => window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
||||
this.refresh = () => {
|
||||
if (this.generalSettings.val.pageReader) {
|
||||
this.chapterWidth = this.chapterElement.scrollWidth;
|
||||
} else {
|
||||
this.chapterHeight = this.chapterElement.scrollHeight + this.paddingTop;
|
||||
}
|
||||
};
|
||||
|
||||
van.derive(() => {
|
||||
const settings = this.readerSettings.val;
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-theme',
|
||||
settings.theme,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-padding',
|
||||
settings.padding + 'px',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textSize',
|
||||
settings.textSize + 'px',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textColor',
|
||||
settings.textColor,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-textAlign',
|
||||
settings.textAlign,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-lineHeight',
|
||||
settings.lineHeight,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--readerSettings-fontFamily',
|
||||
settings.fontFamily,
|
||||
);
|
||||
if (settings.fontFamily) {
|
||||
new FontFace(
|
||||
settings.fontFamily,
|
||||
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
||||
)
|
||||
.load()
|
||||
.then(function (loadedFont) {
|
||||
document.fonts.add(loadedFont);
|
||||
});
|
||||
} else {
|
||||
// have no affect with a font declared in head
|
||||
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (!this.generalSettings.val.pageReader) {
|
||||
this.post({
|
||||
type: 'save',
|
||||
data: parseInt(
|
||||
((window.scrollY + this.layoutHeight) / this.chapterHeight) * 100,
|
||||
10,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.post({
|
||||
type: 'save',
|
||||
data: parseInt(
|
||||
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
|
||||
),
|
||||
});
|
||||
}
|
||||
}, this.autoSaveInterval);
|
||||
|
||||
if (DEBUG) {
|
||||
console = new Object();
|
||||
console.log = function (...data) {
|
||||
reader.post({ 'type': 'console', 'msg': data?.join(' ') });
|
||||
};
|
||||
console.debug = console.log;
|
||||
console.info = console.log;
|
||||
console.warn = console.log;
|
||||
console.error = console.log;
|
||||
}
|
||||
// end reader
|
||||
})();
|
||||
|
||||
window.tts = new (function () {
|
||||
this.readableNodeNames = [
|
||||
'#text',
|
||||
'B',
|
||||
'I',
|
||||
'SPAN',
|
||||
'EM',
|
||||
'BR',
|
||||
'STRONG',
|
||||
'A',
|
||||
];
|
||||
this.prevElement = null;
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
|
||||
this.readable = element => {
|
||||
ele = element ?? this.currentElement;
|
||||
if (
|
||||
ele.nodeName !== 'SPAN' &&
|
||||
this.readableNodeNames.includes(ele.nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!ele.hasChildNodes()) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < ele.childNodes.length; i++) {
|
||||
if (!this.readableNodeNames.includes(ele.childNodes.item(i).nodeName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// if can find a readable node, else stop tts
|
||||
this.findNextTextNode = () => {
|
||||
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
|
||||
return false;
|
||||
} else {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
// is read, have to go next or go back
|
||||
if (this.currentElement.isSameNode(this.prevElement)) {
|
||||
this.prevElement = this.currentElement;
|
||||
if (this.currentElement.nextElementSibling) {
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
} else {
|
||||
// can read? read it
|
||||
if (this.readable()) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!this.prevElement?.parentElement?.isSameNode(this.currentElement) &&
|
||||
this.currentElement.firstElementChild
|
||||
) {
|
||||
// go deep
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.firstElementChild;
|
||||
} else if (this.currentElement.nextElementSibling) {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
}
|
||||
};
|
||||
|
||||
this.next = () => {
|
||||
try {
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
if (this.findNextTextNode()) {
|
||||
this.reading = true;
|
||||
this.speak();
|
||||
} else {
|
||||
this.reading = false;
|
||||
this.stop();
|
||||
document.getElementById('TTS-Controller').firstElementChild.innerHTML =
|
||||
volumnIcon;
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = element => {
|
||||
this.stop();
|
||||
this.currentElement = element ?? reader.chapterElement;
|
||||
this.next();
|
||||
};
|
||||
|
||||
this.resume = () => {
|
||||
if (!this.reading) {
|
||||
if (
|
||||
this.currentElement &&
|
||||
this.currentElement.id !== 'LNReader-chapter'
|
||||
) {
|
||||
this.speak();
|
||||
this.reading = true;
|
||||
} else {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.pause = () => {
|
||||
this.reading = false;
|
||||
reader.post({ type: 'stop-speak' });
|
||||
};
|
||||
|
||||
this.stop = () => {
|
||||
reader.post({ type: 'stop-speak' });
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
this.prevElement = null;
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
};
|
||||
|
||||
this.speak = () => {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement.classList.add('highlight');
|
||||
reader.post({ type: 'speak', data: this.currentElement?.innerText });
|
||||
};
|
||||
})();
|
||||
|
||||
window.pageReader = new (function () {
|
||||
this.page = van.state(0);
|
||||
this.totalPages = van.state(0);
|
||||
|
||||
this.movePage = destPage => {
|
||||
destPage = parseInt(destPage);
|
||||
if (destPage < 0) {
|
||||
return reader.post({ type: 'prev' });
|
||||
}
|
||||
if (destPage >= this.totalPages.val) {
|
||||
return reader.post({ type: 'next' });
|
||||
}
|
||||
this.page.val = destPage;
|
||||
reader.chapterElement.style.transform =
|
||||
'translateX(-' + destPage * 100 + '%)';
|
||||
};
|
||||
|
||||
van.derive(() => {
|
||||
// ignore if initial or other states change
|
||||
if (
|
||||
reader.generalSettings.val.pageReader ===
|
||||
reader.generalSettings.oldVal.pageReader
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const ratio = Math.min(
|
||||
0.99,
|
||||
(window.scrollY + reader.layoutHeight) / reader.chapterHeight,
|
||||
);
|
||||
document.body.classList.add('page-reader');
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
this.totalPages.val = parseInt(
|
||||
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
|
||||
reader.layoutWidth,
|
||||
);
|
||||
this.movePage(this.totalPages.val * ratio);
|
||||
}, 100);
|
||||
} else {
|
||||
reader.chapterElement.style = '';
|
||||
document.body.classList.remove('page-reader');
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
window.scrollTo({
|
||||
top:
|
||||
(reader.chapterHeight * (this.page.val + 1)) / this.totalPages.val -
|
||||
reader.layoutHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// wait for content is rendered
|
||||
setTimeout(() => {
|
||||
reader.refresh();
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
pageReader.totalPages.val = parseInt(
|
||||
(reader.chapterWidth + reader.readerSettings.val.padding * 2) /
|
||||
reader.layoutWidth,
|
||||
);
|
||||
pageReader.movePage(
|
||||
parseInt(
|
||||
pageReader.totalPages.val *
|
||||
Math.min(0.99, reader.chapter.progress / 100),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top:
|
||||
(reader.chapterHeight * reader.chapter.progress) / 100 -
|
||||
reader.layoutHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// click handler
|
||||
(function () {
|
||||
const detectTapPosition = (x, y, horizontal) => {
|
||||
if (horizontal) {
|
||||
if (x < 0.33) {
|
||||
return 'left';
|
||||
}
|
||||
if (x > 0.66) {
|
||||
return 'right';
|
||||
}
|
||||
} else {
|
||||
if (y < 0.33) {
|
||||
return 'top';
|
||||
}
|
||||
if (y > 0.66) {
|
||||
return 'bottom';
|
||||
}
|
||||
}
|
||||
return 'center';
|
||||
};
|
||||
document.onclick = e => {
|
||||
const { clientX, clientY } = e;
|
||||
const { x, y } = {
|
||||
x: clientX / reader.layoutWidth,
|
||||
y: clientY / reader.layoutHeight,
|
||||
};
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const position = detectTapPosition(x, y, true);
|
||||
if (position === 'left') {
|
||||
pageReader.movePage(pageReader.page.val - 1);
|
||||
return;
|
||||
}
|
||||
if (position === 'right') {
|
||||
pageReader.movePage(pageReader.page.val + 1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (reader.generalSettings.val.tapToScroll) {
|
||||
const position = detectTapPosition(x, y, false);
|
||||
if (position === 'top') {
|
||||
window.scrollBy({
|
||||
top: -reader.layoutHeight * 0.75,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (position === 'bottom') {
|
||||
window.scrollBy({
|
||||
top: reader.layoutHeight * 0.75,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.post({ type: 'hide' });
|
||||
};
|
||||
})();
|
||||
|
||||
// swipe handler
|
||||
(function () {
|
||||
this.initialX = null;
|
||||
this.initialY = null;
|
||||
|
||||
reader.chapterElement.addEventListener('touchstart', e => {
|
||||
this.initialX = e.changedTouches[0].screenX;
|
||||
this.initialY = e.changedTouches[0].screenY;
|
||||
});
|
||||
|
||||
reader.chapterElement.addEventListener('touchmove', e => {
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
const diffX =
|
||||
(e.changedTouches[0].screenX - this.initialX) / reader.layoutWidth;
|
||||
reader.chapterElement.style.transition = 'unset';
|
||||
reader.chapterElement.style.transform =
|
||||
'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)';
|
||||
}
|
||||
});
|
||||
|
||||
reader.chapterElement.addEventListener('touchend', e => {
|
||||
const diffX = e.changedTouches[0].screenX - this.initialX;
|
||||
const diffY = e.changedTouches[0].screenY - this.initialY;
|
||||
if (reader.generalSettings.val.pageReader) {
|
||||
reader.chapterElement.style.transition = '200ms';
|
||||
const diffXPercentage = diffX / reader.layoutWidth;
|
||||
if (diffXPercentage < -0.3) {
|
||||
pageReader.movePage(pageReader.page.val + 1);
|
||||
} else if (diffXPercentage > 0.3) {
|
||||
pageReader.movePage(pageReader.page.val - 1);
|
||||
} else {
|
||||
pageReader.movePage(pageReader.page.val);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.target.id?.startsWith('scrollbar') ||
|
||||
e.target.id === 'Image-Modal-img'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
reader.generalSettings.val.swipeGestures &&
|
||||
Math.abs(diffX) > Math.abs(diffY) * 2 &&
|
||||
Math.abs(diffX) > 180
|
||||
) {
|
||||
if (diffX < 0 && this.initialX >= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'next' });
|
||||
} else if (diffX > 0 && this.initialX <= window.innerWidth / 2) {
|
||||
e.preventDefault();
|
||||
reader.post({ type: 'prev' });
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// text options
|
||||
(function () {
|
||||
van.derive(() => {
|
||||
let html = reader.rawHTML;
|
||||
if (reader.generalSettings.val.bionicReading) {
|
||||
html = textVide.textVide(reader.rawHTML);
|
||||
}
|
||||
|
||||
if (reader.generalSettings.val.removeExtraParagraphSpacing) {
|
||||
html = html.replace(/<\s*br[^>]*>/gi, '\n').replace(/\n{2,}/g, '\n\n');
|
||||
}
|
||||
reader.chapterElement.innerHTML = html;
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
const volumnIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M552-152v-75q86-23 139-93.5T744-480q0-89-53.5-158.5T552-734v-75q116 25 190 117t74 211q0 119-73.5 211.5T552-152ZM144-385v-192h144l192-192v576L288-385H144Zm408 55v-302q45 20 70.5 61t25.5 90q0 49-25.5 89.5T552-330Z"/></svg>';
|
||||
const pauseIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M564-228v-504h168v504H564Zm-336 0v-504h168v504H228Z"/></svg>';
|
||||
const resumeIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M240-240v-480h72v480h-72Zm144 0 384-240-384-240v480Z"/></svg>';
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import {
|
||||
ChapterGeneralSettings,
|
||||
ChapterReaderSettings,
|
||||
} from '@hooks/persisted/useSettings';
|
||||
import { State } from './van';
|
||||
|
||||
export interface Reader {
|
||||
// element
|
||||
chapterElement: HTMLElement;
|
||||
viewport: HTMLMetaElement;
|
||||
selection: Selection;
|
||||
|
||||
// state
|
||||
hidden: State<boolean>;
|
||||
generalSettings: State<ChapterGeneralSettings>;
|
||||
readerSettings: State<ChapterReaderSettings>;
|
||||
batteryLevel: State<number>;
|
||||
|
||||
novel: NovelInfo;
|
||||
chapter: ChapterInfo;
|
||||
nextChapter?: ChapterInfo;
|
||||
autoSaveInterval: number;
|
||||
rawHTML: string;
|
||||
strings: {
|
||||
finished: string;
|
||||
nextChapter: string;
|
||||
noNextChapter: string;
|
||||
};
|
||||
|
||||
//layout props
|
||||
paddingTop: number;
|
||||
layoutHeight: number;
|
||||
layoutWidth: number;
|
||||
chapterHeight: number;
|
||||
chapterWidth: number;
|
||||
|
||||
post: (obj: Record<string, any>) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface PageReader {
|
||||
page: State<number>;
|
||||
totalPages: State<number>;
|
||||
movePage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface TTS {
|
||||
started: boolean;
|
||||
reading: boolean;
|
||||
start: (element?: HTMLElement) => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
readable: (element?: HTMLElement) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
const reader: Reader;
|
||||
const tts: TTS;
|
||||
const pageReader: PageReader;
|
||||
}
|
||||
@@ -1,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
@@ -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
@@ -0,0 +1,70 @@
|
||||
import 'typescript';
|
||||
import 'typescript/lib/lib.dom';
|
||||
export interface State<T> {
|
||||
val: T;
|
||||
readonly oldVal: T;
|
||||
readonly rawVal: T;
|
||||
}
|
||||
|
||||
// Defining readonly view of State<T> for covariance.
|
||||
// Basically we want StateView<string> to implement StateView<string | number>
|
||||
export type StateView<T> = Readonly<State<T>>;
|
||||
|
||||
export type Val<T> = State<T> | T;
|
||||
|
||||
export type Primitive = string | number | boolean | bigint;
|
||||
|
||||
export type PropValue = Primitive | ((e: any) => void) | null;
|
||||
|
||||
export type PropValueOrDerived =
|
||||
| PropValue
|
||||
| StateView<PropValue>
|
||||
| (() => PropValue);
|
||||
|
||||
export type Props = Record<string, PropValueOrDerived> & {
|
||||
class?: PropValueOrDerived;
|
||||
};
|
||||
|
||||
export type PropsWithKnownKeys<ElementType> = Partial<{
|
||||
[K in keyof ElementType]: PropValueOrDerived;
|
||||
}>;
|
||||
|
||||
export type ValidChildDomValue = Primitive | Node | null | undefined;
|
||||
|
||||
export type BindingFunc =
|
||||
| ((dom?: Node) => ValidChildDomValue)
|
||||
| ((dom?: Element) => Element);
|
||||
|
||||
export type ChildDom =
|
||||
| ValidChildDomValue
|
||||
| StateView<Primitive | null | undefined>
|
||||
| BindingFunc
|
||||
| readonly ChildDom[];
|
||||
|
||||
export type TagFunc<Result> = (
|
||||
first?: (Props & PropsWithKnownKeys<Result>) | ChildDom,
|
||||
...rest: readonly ChildDom[]
|
||||
) => Result;
|
||||
|
||||
type Tags = Readonly<Record<string, TagFunc<Element>>> & {
|
||||
[K in keyof HTMLElementTagNameMap]: TagFunc<HTMLElementTagNameMap[K]>;
|
||||
};
|
||||
|
||||
declare function state<T>(): State<T>;
|
||||
declare function state<T>(initVal: T): State<T>;
|
||||
|
||||
export interface Van {
|
||||
readonly state: typeof state;
|
||||
readonly derive: <T>(f: () => T) => State<T>;
|
||||
readonly add: (dom: Element, ...children: readonly ChildDom[]) => Element;
|
||||
readonly tags: Tags &
|
||||
((namespaceURI: string) => Readonly<Record<string, TagFunc<Element>>>);
|
||||
readonly hydrate: <T extends Node>(
|
||||
dom: T,
|
||||
f: (dom: T) => T | null | undefined,
|
||||
) => T;
|
||||
}
|
||||
|
||||
declare global {
|
||||
const van: Van;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{let e,t,r,o,n,l,s,f,i,h,w,a,d,u,_,c,S,g,y,b,m,v,j,x,O; s = Object.getPrototypeOf,i = {},h = s(f = {isConnected:1}),w = s(s),a = (e,t,r,o)=>(e ?? (setTimeout(r,o),new Set())).add(t),d = (e,t,o)=>{let n = r; r = t; try {return e(o);} catch (e){return console.error(e),o;} finally {r = n;}},u = e=>e.filter(e=>e.t?.isConnected),_ = e=>n = a(n,e,()=>{for (let e of n){e.o = u(e.o),e.l = u(e.l);}n = l;},1e3),c = {get val(){return r?.i?.add(this),this.rawVal;},get oldVal(){return r?.i?.add(this),this.h;},set val(o){r?.u?.add(this),o !== this.rawVal && (this.rawVal = o,this.o.length + this.l.length ? (t?.add(this),e = a(e,this,x)) : this.h = o);}},S = e=>({__proto__:c,rawVal:e,h:e,o:[],l:[]}),g = (e,t)=>{let r = {i:new Set(),u:new Set()},n = {f:e},l = o; o = []; let s = d(e,r,t); s = (s ?? document).nodeType ? s : new Text(s); for (let e of r.i){r.u.has(e) || (_(e),e.o.push(n));} for (let e of o){e.t = s;} return o = l,n.t = s;},y = (e,t = S(),r)=>{let n = {i:new Set(),u:new Set()},l = {f:e,s:t}; l.t = r ?? o?.push(l) ?? f,t.val = d(e,n,t.rawVal); for (let e of n.i){n.u.has(e) || (_(e),e.l.push(l));} return t;},b = (e,...t)=>{for (let r of t.flat(1 / 0)){let t = s(r ?? 0),o = t === c ? g(()=>r.val) : t === w ? g(r) : r; o != l && e.append(o);} return e;},m = (e,t,...r)=>{let [o,...n] = s(r[0] ?? 0) === h ? r : [{},...r],f = e ? document.createElementNS(e,t) : document.createElement(t); for (let [e,r] of Object.entries(o)){let o = t=>t ? Object.getOwnPropertyDescriptor(t,e) ?? o(s(t)) : l,n = t + ',' + e,h = i[n] ?? (i[n] = o(s(f))?.set ?? 0),a = e.startsWith('on') ? (t,r)=>{let o = e.slice(2); f.removeEventListener(o,r),f.addEventListener(o,t);} : h ? h.bind(f) : f.setAttribute.bind(f,e),d = s(r ?? 0); e.startsWith('on') || d === w && (r = y(r),d = c),d === c ? g(()=>(a(r.val,r.h),f)) : a(r);} return b(f,n);},v = e=>({get:(t,r)=>m.bind(l,e,r)}),j = (e,t)=>t ? t !== e && e.replaceWith(t) : e.remove(),x = ()=>{let r = 0,o = [...e].filter(e=>e.rawVal !== e.h); do {t = new Set(); for (let e of new Set(o.flatMap(e=>e.l = u(e.l)))){y(e.f,e.s,e.t),e.t = l;}} while (++r < 100 && (o = [...t]).length); let n = [...e].filter(e=>e.rawVal !== e.h); e = l; for (let e of new Set(n.flatMap(e=>e.o = u(e.o)))){j(e.t,g(e.f,e.t)),e.t = l;} for (let e of n){e.h = e.rawVal;}},O = {tags:new Proxy(e=>new Proxy(m,v(e)),v()),hydrate:(e,t)=>j(e,g(t,e)),add:b,state:S,derive:y},window.van = O;}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
-167
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-25
@@ -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();
|
||||
}
|
||||
}
|
||||
-93
@@ -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.
|
||||
}
|
||||
}
|
||||
+86
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-30
@@ -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();
|
||||
}
|
||||
}
|
||||
+16
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Generated
+10203
-11520
File diff suppressed because it is too large
Load Diff
+18
-12
@@ -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",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Ref, useCallback } from 'react';
|
||||
import React, { RefObject, useCallback, useRef } from 'react';
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
@@ -6,12 +6,20 @@ import {
|
||||
BottomSheetModalProps,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useBackHandler } from '@hooks/index';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
|
||||
interface BottomSheetProps extends Omit<BottomSheetModalProps, 'ref'> {
|
||||
bottomSheetRef: Ref<BottomSheetModal> | null;
|
||||
bottomSheetRef: RefObject<BottomSheetModalMethods> | null;
|
||||
}
|
||||
|
||||
const BottomSheet: React.FC<BottomSheetProps> = props => {
|
||||
const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
bottomSheetRef,
|
||||
children,
|
||||
onChange,
|
||||
...otherProps
|
||||
}) => {
|
||||
const indexRef = useRef<number>();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
@@ -23,15 +31,26 @@ const BottomSheet: React.FC<BottomSheetProps> = props => {
|
||||
),
|
||||
[],
|
||||
);
|
||||
useBackHandler(() => {
|
||||
if (typeof indexRef.current === 'number' && indexRef.current !== -1) {
|
||||
bottomSheetRef?.current?.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
ref={bottomSheetRef}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleComponent={null}
|
||||
containerStyle={{ paddingBottom: bottom }}
|
||||
{...props}
|
||||
onChange={index => {
|
||||
onChange?.(index);
|
||||
indexRef.current = index;
|
||||
}}
|
||||
{...otherProps}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
text: {
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonWrapper: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,10 +1,9 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { noop } from 'lodash-es';
|
||||
import { BackupCategory, Category, NovelCategory, CCategory } from '../types';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
import { getString } from '@strings/translations';
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
import { db } from '@database/db';
|
||||
|
||||
const getCategoriesQuery = `
|
||||
SELECT * FROM Category ORDER BY sort
|
||||
|
||||
@@ -1,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 = ?';
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const createRepositoryTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS Repository (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
UNIQUE(url)
|
||||
);
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const defaultCover =
|
||||
'https://github.com/LNReader/lnreader-sources/blob/main/icons/src/coverNotAvailable.jpg?raw=true';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const defaultCover =
|
||||
'https://github.com/LNReader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
|
||||
+104
-26
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { TabView, TabBar } from 'react-native-tab-view';
|
||||
|
||||
import { useSearch } from '@hooks';
|
||||
@@ -38,6 +38,18 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
navigation.addListener('tabPress', e => {
|
||||
if (navigation.isFocused()) {
|
||||
e.preventDefault();
|
||||
|
||||
navigation.navigate('GlobalSearchScreen', {});
|
||||
}
|
||||
}),
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const [index, setIndex] = React.useState(0);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
RefreshControl,
|
||||
ListRenderItem,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useBrowseSettings, usePlugins } from '@hooks/persisted';
|
||||
import { PluginItem } from '@plugins/types';
|
||||
import {
|
||||
@@ -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
Reference in New Issue
Block a user