Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
**/node_modules
|
||||
**/node_modules
|
||||
android
|
||||
@@ -10,16 +10,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 +28,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
|
||||
@@ -113,6 +113,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 +145,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)
|
||||
|
||||
@@ -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,113 +1,134 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
padding-top: var(--StatusBar-currentHeight);
|
||||
word-wrap: break-word;
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
padding-top: var(--StatusBar-currentHeight);
|
||||
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;
|
||||
padding-left: var(--readerSettings-padding);
|
||||
padding-right: var(--readerSettings-padding);
|
||||
padding-bottom: 40px;
|
||||
|
||||
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;
|
||||
display: block;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.nextButton {
|
||||
min-height: 40px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
min-height: 40px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.infoText {
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
border: none;
|
||||
margin: 0px;
|
||||
color: inherit;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
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;
|
||||
min-height: var(--chapterCtn-height);
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ToolWrapper {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 5vw;
|
||||
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;
|
||||
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) {
|
||||
@@ -117,303 +138,303 @@ img {
|
||||
}
|
||||
|
||||
#ToolWrapper.hidden {
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#ToolWrapper.hidden.horizontal {
|
||||
opacity: 0;
|
||||
bottom: 0;
|
||||
right: unset;
|
||||
opacity: 0;
|
||||
bottom: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
#TTS-Controller {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #TTS-Controller {
|
||||
margin-left: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
#TTS-Controller button{
|
||||
background: var(--theme-surface-0-9);
|
||||
border: 0;
|
||||
#TTS-Controller button {
|
||||
background: var(--theme-surface-0-9);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#TTS-Controller svg {
|
||||
fill: var(--theme-onSurface);
|
||||
fill: var(--theme-onSurface);
|
||||
}
|
||||
|
||||
#ToolWrapper > #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;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--theme-onSurface);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--theme-onSurface);
|
||||
}
|
||||
|
||||
#ScrollBar > .scrollbar-item.scrollbar-text {
|
||||
height: 12.5%;
|
||||
height: 12.5%;
|
||||
}
|
||||
|
||||
#ScrollBar > .scrollbar-item#scrollbar-slider {
|
||||
height: 75%;
|
||||
align-items: unset;
|
||||
height: 75%;
|
||||
align-items: unset;
|
||||
}
|
||||
|
||||
#ScrollBar #scrollbar-track {
|
||||
width: 0.1rem;
|
||||
height: 100%;
|
||||
background-color: var(--theme-outline);
|
||||
width: 0.1rem;
|
||||
height: 100%;
|
||||
background-color: var(--theme-outline);
|
||||
}
|
||||
|
||||
#ScrollBar #scrollbar-progress {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
background-color: var(--theme-primary);
|
||||
width: 100%;
|
||||
height: 0;
|
||||
background-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
#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);
|
||||
position: relative;
|
||||
top: 100%;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-0.95rem, -1rem);
|
||||
}
|
||||
|
||||
#ScrollBar #scrollbar-thumb {
|
||||
top: 100%;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 100%;
|
||||
background-color: var(--theme-primary);
|
||||
top: 100%;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 100%;
|
||||
background-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar{
|
||||
display: flex;
|
||||
width: 80vw;
|
||||
height: 2.4rem;
|
||||
min-height: unset;
|
||||
align-items: center;
|
||||
margin-top: unset;
|
||||
#ToolWrapper.horizontal > #ScrollBar {
|
||||
display: flex;
|
||||
width: 80vw;
|
||||
height: 2.4rem;
|
||||
min-height: unset;
|
||||
align-items: center;
|
||||
margin-top: unset;
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--theme-onSurface);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--theme-onSurface);
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item.scrollbar-text {
|
||||
width: 12.5%;
|
||||
width: 12.5%;
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar > .scrollbar-item#scrollbar-slider {
|
||||
width: 75%;
|
||||
align-items: center;
|
||||
width: 75%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar #scrollbar-track {
|
||||
height: 0.1rem;
|
||||
width: 100%;
|
||||
background-color: var(--theme-outline);
|
||||
height: 0.1rem;
|
||||
width: 100%;
|
||||
background-color: var(--theme-outline);
|
||||
}
|
||||
|
||||
#ToolWrapper.horizontal > #ScrollBar #scrollbar-progress {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background-color: var(--theme-primary);
|
||||
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);
|
||||
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);
|
||||
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: 3;
|
||||
padding: 0.2rem 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 {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
.reader-footer-item:first-child {
|
||||
text-align: left;
|
||||
text-align: left;
|
||||
}
|
||||
.reader-footer-item:last-child {
|
||||
text-align: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tts.highlight {
|
||||
color: var(--theme-onSecondary);
|
||||
background-color: var(--theme-secondary);
|
||||
.highlight {
|
||||
color: var(--theme-onSecondary);
|
||||
background-color: var(--theme-secondary);
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--theme-surface-0-9);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
backdrop-filter: blur(1px);
|
||||
position: fixed;
|
||||
top: var(--top);
|
||||
left: var(--left);
|
||||
-webkit-animation: menuAnimation 0.4s 0s both;
|
||||
animation: menuAnimation 0.4s 0s both;
|
||||
transform-origin: left;
|
||||
list-style: none;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999999999;
|
||||
position: absolute;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--theme-surface-0-9);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
backdrop-filter: blur(1px);
|
||||
position: fixed;
|
||||
top: var(--top);
|
||||
left: var(--left);
|
||||
-webkit-animation: menuAnimation 0.4s 0s both;
|
||||
animation: menuAnimation 0.4s 0s both;
|
||||
transform-origin: left;
|
||||
list-style: none;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999999999;
|
||||
}
|
||||
|
||||
.contextMenu-item {
|
||||
padding: 4px,
|
||||
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);
|
||||
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);
|
||||
fill: var(--theme-onSurface);
|
||||
}
|
||||
.contextMenu-button span {
|
||||
margin-left: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes menuAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
height: var(--height);
|
||||
opacity: 1;
|
||||
border-radius: 8px;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes menuItemAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuItemAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
#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%;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -101,14 +101,19 @@ class Reader {
|
||||
'--readerSettings-fontFamily',
|
||||
settings.fontFamily,
|
||||
);
|
||||
new FontFace(
|
||||
settings.fontFamily,
|
||||
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
||||
)
|
||||
.load()
|
||||
.then(function (loadedFont) {
|
||||
document.fonts.add(loadedFont);
|
||||
});
|
||||
if (settings.fontFamily) {
|
||||
new FontFace(
|
||||
settings.fontFamily,
|
||||
'url("file:///android_asset/fonts/' + settings.fontFamily + '.ttf")',
|
||||
)
|
||||
.load()
|
||||
.then(function (loadedFont) {
|
||||
document.fonts.add(loadedFont);
|
||||
});
|
||||
} else {
|
||||
// have no affect with a font declared in head
|
||||
document.fonts.forEach(fontFace => document.fonts.delete(fontFace));
|
||||
}
|
||||
};
|
||||
updateGeneralSettings = settings => {
|
||||
this.showScrollPercentage = settings.showScrollPercentage;
|
||||
@@ -314,12 +319,15 @@ class SwipeHandler {
|
||||
/**
|
||||
* @type {import('./type').TextToSpeech}
|
||||
*/
|
||||
|
||||
// Walk through all leave -> convert each leaf to list of tts elements -> read through all tts elements
|
||||
class TextToSpeech {
|
||||
constructor(reader) {
|
||||
this.reader = reader;
|
||||
(this.leaf = this.reader.chapter), (this.TTSWrapper = null); // swap these 2 elements
|
||||
this.TTSEle = null;
|
||||
this.readableNodeNames = ['#text', 'B', 'I', 'SPAN', 'EM', 'BR', 'STRONG'];
|
||||
this.leaf = this.reader.chapter;
|
||||
this.reading = false;
|
||||
this.started = false;
|
||||
this.$ = document.getElementById('TTS-Controller');
|
||||
this.$.classList.add('d-none');
|
||||
this.$.onclick = () => {
|
||||
@@ -332,9 +340,11 @@ class TextToSpeech {
|
||||
}
|
||||
|
||||
start = () => {
|
||||
this.started = false;
|
||||
this.$.innerHTML = `<button>${pauseIcon}</button>`;
|
||||
this.$.classList.remove('d-none');
|
||||
this.stop();
|
||||
this.leaf = this.reader.chapter;
|
||||
this.next();
|
||||
};
|
||||
|
||||
@@ -343,11 +353,11 @@ class TextToSpeech {
|
||||
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();
|
||||
while (this.readableNodeNames.includes(this.leaf.nodeName)) {
|
||||
this.leaf = this.leaf.parentNode;
|
||||
}
|
||||
this.reader.selection.removeAllRanges();
|
||||
this.speak();
|
||||
} else {
|
||||
this.next();
|
||||
@@ -359,77 +369,58 @@ class TextToSpeech {
|
||||
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)
|
||||
);
|
||||
if (
|
||||
this.leaf.nodeName !== 'SPAN' &&
|
||||
this.readableNodeNames.includes(this.leaf.nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!this.leaf.hasChildNodes()) {
|
||||
return false;
|
||||
}
|
||||
const a = [];
|
||||
for (let i = 0; i < this.leaf.childNodes.length; i++) {
|
||||
if (
|
||||
!this.readableNodeNames.includes(this.leaf.childNodes.item(i).nodeName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
findNextTextNode() {
|
||||
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)) {
|
||||
this.leaf = this.leaf.firstElementChild;
|
||||
} else {
|
||||
if (this.leaf.firstElementChild) {
|
||||
this.leaf = this.leaf.firstElementChild;
|
||||
} else if (this.leaf.nextElementSibling) {
|
||||
this.leaf = this.leaf.nextElementSibling;
|
||||
} else {
|
||||
this.leaf = this.leaf.parentElement;
|
||||
}
|
||||
}
|
||||
} while (!this.readable());
|
||||
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;
|
||||
},
|
||||
);
|
||||
this.TTSEle = this.TTSWrapper.firstElementChild;
|
||||
this.leaf.replaceWith(this.TTSWrapper);
|
||||
}
|
||||
|
||||
next = () => {
|
||||
try {
|
||||
if (this.TTSEle) {
|
||||
this.TTSEle.classList.remove('highlight');
|
||||
}
|
||||
this.leaf?.classList?.remove('highlight');
|
||||
if (this.reading) {
|
||||
if (this.TTSEle && this.TTSEle.nextElementSibling) {
|
||||
this.TTSEle = this.TTSEle.nextElementSibling;
|
||||
if (this.leaf.nextElementSibling) {
|
||||
this.leaf = this.leaf.nextElementSibling;
|
||||
} else {
|
||||
this.findNextTextNode();
|
||||
}
|
||||
} else {
|
||||
if (!this.TTSEle) {
|
||||
this.findNextTextNode();
|
||||
}
|
||||
} else if (!this.started) {
|
||||
this.findNextTextNode();
|
||||
this.started = true;
|
||||
}
|
||||
this.speak();
|
||||
} catch (e) {
|
||||
@@ -438,15 +429,10 @@ class TextToSpeech {
|
||||
};
|
||||
|
||||
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' });
|
||||
this.$.classList.add('d-none');
|
||||
this.leaf?.classList?.remove('highlight');
|
||||
this.reading = false;
|
||||
};
|
||||
|
||||
pause = () => {
|
||||
@@ -455,15 +441,13 @@ class TextToSpeech {
|
||||
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() });
|
||||
this.leaf.classList.add('highlight');
|
||||
this.reader.post({ type: 'speak', data: this.leaf?.innerText });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -481,22 +465,23 @@ class ContextMenu {
|
||||
name: 'Start Reading',
|
||||
icon: volumeIcon,
|
||||
action: () => {
|
||||
tts.start();
|
||||
this.closeMenu();
|
||||
tts.start();
|
||||
},
|
||||
}),
|
||||
START_HERE: this.renderItem({
|
||||
name: 'Start Here',
|
||||
icon: selectVolumeIcon,
|
||||
action: () => {
|
||||
tts.startHere();
|
||||
this.closeMenu();
|
||||
tts.startHere();
|
||||
},
|
||||
}),
|
||||
COPY: this.renderItem({
|
||||
name: 'Copy',
|
||||
icon: copyIcon,
|
||||
action: () => {
|
||||
this.closeMenu();
|
||||
this.reader.post({
|
||||
type: 'copy',
|
||||
data: this.reader.selection.toString(),
|
||||
|
||||
@@ -1,111 +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
|
||||
);
|
||||
})({});
|
||||
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;}({});
|
||||
|
||||
+2
-3
@@ -30,10 +30,9 @@ interface Reader {
|
||||
|
||||
interface TextToSpeech {
|
||||
reader: Reader;
|
||||
leaf: HTMLElement;
|
||||
TTSWrapper: HTMLElement;
|
||||
TTSEle: HTMLElement;
|
||||
leaf: HTMLUnknownElement;
|
||||
reading: boolean;
|
||||
readableTags: string[];
|
||||
start: () => void;
|
||||
startHere: () => void;
|
||||
resume: () => void;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.rajarsheechatterjee.EpubUtil
|
||||
|
||||
class ChapterEntry (val name: String, val href: String)
|
||||
@@ -1,163 +0,0 @@
|
||||
package com.rajarsheechatterjee.EpubUtil;
|
||||
|
||||
import android.util.Xml;
|
||||
|
||||
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 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 org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
public class EpubUtil extends ReactContextBaseJavaModule {
|
||||
EpubUtil(ReactApplicationContext context){super(context);}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return "EpubUtil";
|
||||
}
|
||||
|
||||
private XmlPullParser initParse(File file) throws XmlPullParserException, IOException {
|
||||
XmlPullParser parser = Xml.newPullParser();
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
|
||||
parser.setInput(new FileInputStream(file), null);
|
||||
parser.nextTag();
|
||||
return parser;
|
||||
}
|
||||
|
||||
private String readText(XmlPullParser parser) throws IOException, XmlPullParserException {
|
||||
String result = "";
|
||||
if (parser.next() == XmlPullParser.TEXT) {
|
||||
result = parser.getText();
|
||||
parser.nextTag();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String cleanUrl(String url){
|
||||
if(url != null){
|
||||
return url.replaceFirst("#[^.]+?$", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void parseNovelAndChapters(String epubDirPath, Promise promise) {
|
||||
try {
|
||||
File containerFile = new File(epubDirPath, "META-INF/container.xml");
|
||||
File contentMetaFile = new File(epubDirPath, getContentMetaFilePath(containerFile));
|
||||
String contentDir = contentMetaFile.getParent();
|
||||
|
||||
ReadableMap novel = getNovelMetadata(contentMetaFile, contentDir);
|
||||
promise.resolve(novel);
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
promise.reject(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private String getContentMetaFilePath(File file) throws XmlPullParserException, IOException {
|
||||
XmlPullParser parser = initParse(file);
|
||||
while (parser.next() != XmlPullParser.END_TAG){
|
||||
@Nullable String tag = parser.getName();
|
||||
if(tag != null && tag.equals("rootfile")) {
|
||||
return parser.getAttributeValue(null, "full-path");
|
||||
}
|
||||
}
|
||||
return "OEBPS/content.opf"; // default
|
||||
}
|
||||
private ReadableMap getNovelMetadata(File file, String contentDir) throws XmlPullParserException, IOException {
|
||||
WritableMap novel = new WritableNativeMap();
|
||||
WritableArray chapters = new WritableNativeArray();
|
||||
XmlPullParser parser = initParse(file);
|
||||
HashMap<String, String> refMap = new HashMap<>();
|
||||
HashMap<String, String> tocMap = new HashMap<>();
|
||||
File tocFile = new File(contentDir, "toc.ncx");
|
||||
if(tocFile.exists()){
|
||||
XmlPullParser tocParser = initParse(tocFile);
|
||||
String label = null;
|
||||
while (tocParser.next() != XmlPullParser.END_DOCUMENT){
|
||||
String tag = tocParser.getName();
|
||||
if(tag != null){
|
||||
if(tag.equals("text")){
|
||||
label = readText(tocParser);
|
||||
}else if(tag.equals("content")){
|
||||
String href = cleanUrl(tocParser.getAttributeValue(null, "src"));
|
||||
if(href != null){
|
||||
tocMap.put(href, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String cover = null;
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT){
|
||||
@Nullable String tag = parser.getName();
|
||||
if(tag != null){
|
||||
switch (tag) {
|
||||
case "item": {
|
||||
String id = parser.getAttributeValue(null, "id");
|
||||
String href = parser.getAttributeValue(null, "href");
|
||||
if (id != null) {
|
||||
refMap.put(id, href);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "itemref": {
|
||||
String idRef = parser.getAttributeValue(null, "idref");
|
||||
String href = refMap.get(idRef);
|
||||
if (href != null) {
|
||||
WritableMap chapter = new WritableNativeMap();
|
||||
chapter.putString("path", contentDir + "/" + href);
|
||||
String name = tocMap.get(href);
|
||||
chapter.putString("name", name == null ? href : name);
|
||||
chapters.pushMap(chapter);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "title":
|
||||
novel.putString("name", readText(parser));
|
||||
break;
|
||||
case "creator":
|
||||
novel.putString("author", readText(parser));
|
||||
break;
|
||||
case "contributor":
|
||||
novel.putString("artist", readText(parser));
|
||||
break;
|
||||
case "description":
|
||||
novel.putString("summary", readText(parser));
|
||||
break;
|
||||
case "meta":
|
||||
String metaName = parser.getAttributeValue(null, "name");
|
||||
if(metaName != null && metaName.equals("cover")){
|
||||
cover = parser.getAttributeValue(null, "content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
parser.next();
|
||||
}
|
||||
}
|
||||
if(cover != null){
|
||||
String coverPath = contentDir + "/" + refMap.get(cover);
|
||||
novel.putString("cover", coverPath);
|
||||
}
|
||||
novel.putArray("chapters", chapters);
|
||||
return novel;
|
||||
}
|
||||
}
|
||||
@@ -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 Error("Table of content doesn't 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
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.rajarsheechatterjee.EpubUtil;
|
||||
|
||||
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 com.rajarsheechatterjee.ZipArchive.ZipArchive;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class EpubUtilPackage implements ReactPackage {
|
||||
@NonNull
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactApplicationContext) {
|
||||
List<NativeModule> modules = new ArrayList<>();
|
||||
try {
|
||||
modules.add(new EpubUtil(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.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,82 +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.EpubUtil.EpubUtilPackage;
|
||||
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());
|
||||
packages.add(new EpubUtilPackage());
|
||||
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)
|
||||
}
|
||||
}
|
||||
-158
@@ -1,158 +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 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);
|
||||
}
|
||||
|
||||
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 (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(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(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));
|
||||
}
|
||||
}
|
||||
|
||||
@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,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
+1493
-1777
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -3,24 +3,25 @@
|
||||
"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",
|
||||
"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": {
|
||||
"@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,6 +34,7 @@
|
||||
"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",
|
||||
@@ -41,27 +43,26 @@
|
||||
"i18n-js": "^3.8.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"protobufjs": "^7.2.6",
|
||||
"qs": "^6.11.2",
|
||||
"qs": "^6.12.0",
|
||||
"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-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-vector-icons": "^9.0.0",
|
||||
"react-native-webview": "13.2.2",
|
||||
"sanitize-html": "^2.7.0",
|
||||
"react-native-webview": "^13.2.2",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"urlencode": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -75,7 +76,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,23 @@
|
||||
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}`;
|
||||
|
||||
fs.writeFile(path.join(__dirname, '..', '.env'), data, 'utf8', err => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
}
|
||||
});
|
||||
@@ -6,10 +6,11 @@ import { coverPlaceholderColor } from '../theme/colors';
|
||||
|
||||
import color from 'color';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import { LibraryNovelInfo } from '@database/types';
|
||||
|
||||
interface ListViewProps {
|
||||
item: NovelInfo;
|
||||
item: NovelItem | LibraryNovelInfo;
|
||||
downloadBadge?: React.ReactNode;
|
||||
unreadBadge?: React.ReactNode;
|
||||
inLibraryBadge?: React.ReactNode;
|
||||
|
||||
+116
-68
@@ -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,25 +86,35 @@ function NovelCover<TNovel extends NovelItem | NovelInfo | LibraryNovelInfo>({
|
||||
|
||||
const coverHeight = useMemo(
|
||||
() => (window.width / numColumns) * (4 / 3),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[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,
|
||||
},
|
||||
backgroundColor: theme.primary,
|
||||
} &&
|
||||
styles.selectedNovelCover,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
@@ -102,22 +128,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 +155,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 +175,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 +185,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 +218,9 @@ const ComfortableTitle = ({
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
styles.title,
|
||||
styles.padding4,
|
||||
{
|
||||
color: theme.onSurface,
|
||||
padding: 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -204,18 +234,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 +248,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 +277,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 +289,7 @@ const UnreadBadge = ({
|
||||
</Text>
|
||||
);
|
||||
|
||||
const DownloadBadge = ({
|
||||
const DownloadBadge: React.FC<DownloadBadgeProps> = ({
|
||||
chaptersDownloaded,
|
||||
showUnreadBadges,
|
||||
chaptersUnread,
|
||||
@@ -272,13 +298,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 +311,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 +387,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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './tables/ChapterTable';
|
||||
import { dbTxnErrorCallback } from './utils/helpers';
|
||||
import { noop } from 'lodash-es';
|
||||
import { createRepositoryTableQuery } from './tables/RepositoryTable';
|
||||
|
||||
const dbName = 'lnreader.db';
|
||||
|
||||
@@ -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,21 +1,16 @@
|
||||
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';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
const insertChapterQuery = `
|
||||
INSERT OR IGNORE INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -42,7 +37,7 @@ export const insertChapters = async (
|
||||
index,
|
||||
],
|
||||
(txObj, { insertId }) => {
|
||||
if (!insertId) {
|
||||
if (!insertId || insertId < 0) {
|
||||
tx.executeSql(
|
||||
`
|
||||
UPDATE Chapter SET
|
||||
@@ -95,6 +90,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,
|
||||
@@ -127,7 +137,7 @@ const getPrevChapterQuery = `
|
||||
export const getPrevChapter = (
|
||||
novelId: number,
|
||||
chapterId: number,
|
||||
): Promise<ChapterInfo> => {
|
||||
): Promise<ChapterInfo | null> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
@@ -158,7 +168,7 @@ const getNextChapterQuery = `
|
||||
export const getNextChapter = (
|
||||
novelId: number,
|
||||
chapterId: number,
|
||||
): Promise<ChapterInfo> => {
|
||||
): Promise<ChapterInfo | null> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
@@ -216,109 +226,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'));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ 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
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ 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 +12,10 @@ 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';
|
||||
|
||||
export const insertNovelAndChapters = async (
|
||||
pluginId: string,
|
||||
@@ -43,28 +45,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 +78,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 +116,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 +285,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,65 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
import { Repository } from '@database/types';
|
||||
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
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),
|
||||
);
|
||||
@@ -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.05).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,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,
|
||||
@@ -25,20 +25,18 @@ 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 dayjs from 'dayjs';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import FileManager from '@native/FileManager';
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -50,7 +48,9 @@ export interface NovelSettings {
|
||||
showChapterTitles?: boolean;
|
||||
}
|
||||
|
||||
const defaultNovelSettings: NovelSettings = {};
|
||||
const defaultNovelSettings: NovelSettings = {
|
||||
showChapterTitles: true,
|
||||
};
|
||||
const defaultPageIndex = 0;
|
||||
|
||||
export const useTrackedNovel = (novelId: number) => {
|
||||
@@ -315,42 +315,22 @@ 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;
|
||||
}
|
||||
if (sourceNovel.latestChapter) {
|
||||
setMMKVObject(
|
||||
`${NOVEL_LATEST_CHAPTER_PREFIX}_${novel.id}`,
|
||||
sourceNovel.latestChapter,
|
||||
);
|
||||
}
|
||||
}
|
||||
let pages: string[];
|
||||
if (novel.totalPages > 0) {
|
||||
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);
|
||||
}
|
||||
@@ -365,15 +345,13 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
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,
|
||||
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 {
|
||||
@@ -382,8 +360,6 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
};
|
||||
});
|
||||
await insertChapters(novel.id, sourceChapters);
|
||||
hasUpdates[pageIndex] = false;
|
||||
setMMKVObject(hasUpdatesKey, hasUpdates);
|
||||
chapters = await _getPageChapters(
|
||||
novel.id,
|
||||
novelSettings.sort,
|
||||
@@ -392,14 +368,14 @@ export const useNovel = (novelPath: string, pluginId: string) => {
|
||||
);
|
||||
}
|
||||
setChapters(chapters);
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [novel, novelSettings, pageIndex]);
|
||||
useEffect(() => {
|
||||
getNovel();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
getChapters();
|
||||
getChapters().catch(e => showToast(e.message));
|
||||
}, [getChapters]);
|
||||
|
||||
return {
|
||||
@@ -434,8 +410,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}`,
|
||||
);
|
||||
@@ -443,10 +417,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();
|
||||
|
||||
@@ -47,7 +47,14 @@ export default function usePlugins() {
|
||||
);
|
||||
setFilteredAvailablePlugins(
|
||||
orderBy(
|
||||
availablePlugins.filter(plg => filter.includes(plg.lang)),
|
||||
availablePlugins
|
||||
.filter(
|
||||
avalilablePlugin =>
|
||||
!installedPlugins.some(
|
||||
installedPlugin => installedPlugin.id === avalilablePlugin.id,
|
||||
),
|
||||
)
|
||||
.filter(plg => filter.includes(plg.lang)),
|
||||
'name',
|
||||
),
|
||||
);
|
||||
@@ -94,13 +101,10 @@ 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<PluginItem[]>(AVAILABLE_PLUGINS) || [];
|
||||
|
||||
const actualPlugin: PluginItem = {
|
||||
...plugin,
|
||||
version: _plg.version,
|
||||
@@ -109,10 +113,6 @@ export default function usePlugins() {
|
||||
if (!installedPlugins.some(plg => plg.id === plugin.id)) {
|
||||
setMMKVObject(INSTALLED_PLUGINS, [...installedPlugins, actualPlugin]);
|
||||
}
|
||||
setMMKVObject(
|
||||
AVAILABLE_PLUGINS,
|
||||
availablePlugins.filter(plg => plg.id !== plugin.id),
|
||||
);
|
||||
filterPlugins(languagesFilter);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -128,14 +128,6 @@ export default function usePlugins() {
|
||||
}
|
||||
const installedPlugins =
|
||||
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS) || [];
|
||||
const availablePlugins =
|
||||
getMMKVObject<PluginItem[]>(AVAILABLE_PLUGINS) || [];
|
||||
|
||||
// safe
|
||||
if (!availablePlugins.some(_plg => _plg.id === plugin.id)) {
|
||||
availablePlugins.push(plugin);
|
||||
setMMKVObject(AVAILABLE_PLUGINS, availablePlugins);
|
||||
}
|
||||
setMMKVObject(
|
||||
INSTALLED_PLUGINS,
|
||||
installedPlugins.filter(plg => plg.id !== plugin.id),
|
||||
|
||||
@@ -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';
|
||||
@@ -100,6 +101,11 @@ export interface ChapterReaderSettings {
|
||||
customCSS: string;
|
||||
customJS: string;
|
||||
customThemes: ReaderTheme[];
|
||||
tts?: {
|
||||
voice?: Voice;
|
||||
rate?: number;
|
||||
pitch?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const initialAppSettings: AppSettings = {
|
||||
@@ -173,6 +179,10 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
customCSS: '',
|
||||
customJS: '',
|
||||
customThemes: [],
|
||||
tts: {
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const useAppSettings = () => {
|
||||
|
||||
@@ -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;
|
||||
+45
-13
@@ -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';
|
||||
|
||||
@@ -28,27 +32,30 @@ 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';
|
||||
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,13 +65,38 @@ 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 (
|
||||
<NavigationContainer theme={{ colors: theme, dark: theme.isDark }}>
|
||||
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
|
||||
|
||||
@@ -14,6 +14,7 @@ import DownloadQueue from '../screens/more/DownloadQueueScreen';
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export type RootStackParamList = {
|
||||
WebviewScreen: {
|
||||
name: string;
|
||||
url: string;
|
||||
pluginId?: string;
|
||||
pluginId: string;
|
||||
isNovel?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -80,6 +80,7 @@ export type SettingsStackParamList = {
|
||||
AppearanceSettings: undefined;
|
||||
AdvancedSettings: undefined;
|
||||
LibrarySettings: undefined;
|
||||
RespositorySettings: undefined;
|
||||
};
|
||||
|
||||
export type NovelScreenProps = StackScreenProps<RootStackParamList, 'Novel'>;
|
||||
|
||||
@@ -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';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { parse as parseProto } from 'protobufjs';
|
||||
|
||||
type FetchInit = {
|
||||
@@ -49,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(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import RNFS from 'react-native-fs';
|
||||
import { PluginDownloadFolder } from '@utils/constants/download';
|
||||
import { reverse, uniqBy } from 'lodash-es';
|
||||
import { newer } from '@utils/compareVersion';
|
||||
import { store } from './helpers/storage';
|
||||
|
||||
// packages for plugins
|
||||
import { load } from 'cheerio';
|
||||
@@ -9,13 +9,15 @@ import qs from 'qs';
|
||||
import { NovelStatus, Plugin, PluginItem } from './types';
|
||||
import { FilterTypes } from './types/filterTypes';
|
||||
import { isUrlAbsolute } from './helpers/isAbsoluteUrl';
|
||||
import { fetchApi, fetchFile, fetchProto, 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 { Parser } from 'htmlparser2';
|
||||
import TextFile from '@native/TextFile';
|
||||
|
||||
const pluginsFilePath = PluginDownloadFolder + '/plugins.json';
|
||||
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 },
|
||||
@@ -24,18 +26,24 @@ const packages: Record<string, any> = {
|
||||
'qs': qs,
|
||||
'urlencode': { encode, decode },
|
||||
'@libs/novelStatus': { NovelStatus },
|
||||
'@libs/fetch': { fetchApi, fetchFile, fetchText, fetchProto },
|
||||
'@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',
|
||||
@@ -51,67 +59,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;
|
||||
}
|
||||
@@ -119,24 +102,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<PluginItem[]> => {
|
||||
// plugins host
|
||||
const githubUsername = 'LNReader';
|
||||
const githubRepository = 'lnreader-sources';
|
||||
const pluginsTag = 'v2.1.0';
|
||||
return fetch(
|
||||
`https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/plugins/${pluginsTag}/.dist/plugins.min.json`,
|
||||
).then(res => res.json());
|
||||
const fetchPlugins = async (): Promise<PluginItem[]> => {
|
||||
const allPlugins: PluginItem[] = [];
|
||||
const allRepositories = await getRepositoriesFromDb();
|
||||
|
||||
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';
|
||||
|
||||
@@ -145,7 +157,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,6 +1,7 @@
|
||||
import { FilterToValues, Filters } from './filterTypes';
|
||||
|
||||
export interface NovelItem {
|
||||
id: undefined;
|
||||
name: string;
|
||||
path: string;
|
||||
cover?: string;
|
||||
@@ -32,7 +33,6 @@ export interface SourceNovel extends NovelItem {
|
||||
status?: NovelStatus;
|
||||
chapters: ChapterItem[];
|
||||
totalPages?: number;
|
||||
latestChapter?: ChapterItem;
|
||||
}
|
||||
|
||||
export interface SourcePage {
|
||||
@@ -53,10 +53,20 @@ export interface PluginItem {
|
||||
version: string;
|
||||
url: string; // the url of raw code
|
||||
iconUrl: string;
|
||||
customJS?: string;
|
||||
customCSS?: string;
|
||||
hasUpdate?: 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;
|
||||
popularNovels: (
|
||||
pageNo: number,
|
||||
@@ -66,6 +76,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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
),
|
||||
);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || String(error);
|
||||
setSearchResults(prevState =>
|
||||
prevState.map(prevResult =>
|
||||
prevResult.plugin.id === _plugin.id
|
||||
@@ -77,7 +78,7 @@ export const useGlobalSearch = ({ defaultSearchText }: Props) => {
|
||||
...prevResult,
|
||||
novels: [],
|
||||
isLoading: false,
|
||||
error: error.message,
|
||||
error: errorMessage,
|
||||
}
|
||||
: { ...prevResult },
|
||||
),
|
||||
|
||||
@@ -2,16 +2,29 @@ import React, { useRef, useState } from 'react';
|
||||
import WebView, { WebViewNavigation } from 'react-native-webview';
|
||||
import { ProgressBar } from 'react-native-paper';
|
||||
|
||||
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 theme = useTheme();
|
||||
const webViewRef = useRef<WebView | null>(null);
|
||||
@@ -21,31 +34,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 +98,24 @@ 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))
|
||||
}
|
||||
/>
|
||||
{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,
|
||||
},
|
||||
});
|
||||
@@ -87,22 +87,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 +132,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>
|
||||
@@ -172,6 +152,24 @@ export const InstalledTab = memo(
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
{item.hasUpdate ? (
|
||||
<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}
|
||||
@@ -302,7 +300,7 @@ const AvailablePluginCard = ({
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<IconButtonV2
|
||||
name="download"
|
||||
name="download-outline"
|
||||
color={theme.primary}
|
||||
onPress={() => {
|
||||
ratio.value = withTiming(0, { duration: 500 });
|
||||
|
||||
@@ -100,7 +100,7 @@ const createStyleSheet = (pictureHeight: number, pictureWidth: number) => {
|
||||
borderRadius: 4,
|
||||
},
|
||||
listLoadingContainer: {
|
||||
marginHorizontal: 12,
|
||||
marginHorizontal: 8,
|
||||
marginVertical: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -9,9 +9,13 @@ import { useDeviceOrientation } from '@hooks';
|
||||
|
||||
interface Props {
|
||||
theme: ThemeColors;
|
||||
completeRow?: number;
|
||||
}
|
||||
|
||||
const SourceScreenSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
const SourceScreenSkeletonLoading: React.FC<Props> = ({
|
||||
theme,
|
||||
completeRow,
|
||||
}) => {
|
||||
const [highlightColor, backgroundColor] = getLoadingColors(theme);
|
||||
|
||||
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
|
||||
@@ -31,23 +35,24 @@ const SourceScreenSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
let height = (window.width / numColumns) * (4 / 3);
|
||||
let width = (window.width - 12 - 9.6 * numColumns) / numColumns;
|
||||
return [height, width];
|
||||
}, [numColumns]);
|
||||
}, [numColumns, window.width]);
|
||||
|
||||
const renderLoadingNovel = (item: number, index: number) => {
|
||||
const renderLoadingNovel = (item: number) => {
|
||||
let randomNumber = Math.random();
|
||||
randomNumber < 0.1 ? (randomNumber = 0) : null;
|
||||
return (
|
||||
<LoadingNovel
|
||||
key={index}
|
||||
backgroundColor={backgroundColor}
|
||||
highlightColor={highlightColor}
|
||||
pictureHeight={pictureHeight}
|
||||
pictureWidth={pictureWidth}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
<View key={'sourceLoading' + item} style={{ flex: 1 / numColumns }}>
|
||||
<LoadingNovel
|
||||
backgroundColor={backgroundColor}
|
||||
highlightColor={highlightColor}
|
||||
pictureHeight={pictureHeight}
|
||||
pictureWidth={pictureWidth}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const renderLoading = (item: number, index: number) => {
|
||||
const renderLoading = (item: number) => {
|
||||
const offset = Math.pow(10, item);
|
||||
const items: Array<number> = [1 * offset];
|
||||
if (displayMode !== DisplayModes.List) {
|
||||
@@ -56,12 +61,16 @@ const SourceScreenSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View key={index} style={styles.row}>
|
||||
<View key={'sourceSkeletonRow' + item} style={styles.row}>
|
||||
{items.map(renderLoadingNovel)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
let items: Array<number> = [];
|
||||
if (completeRow === 1) {
|
||||
return renderLoadingNovel(completeRow);
|
||||
}
|
||||
|
||||
if (displayMode === DisplayModes.List) {
|
||||
items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
} else {
|
||||
@@ -69,6 +78,7 @@ const SourceScreenSkeletonLoading: React.FC<Props> = ({ theme }) => {
|
||||
items.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return <View style={styles.container}>{items.map(renderLoading)}</View>;
|
||||
};
|
||||
|
||||
@@ -76,15 +86,23 @@ const createStyleSheet = () => {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
marginHorizontal: 4,
|
||||
marginHorizontal: 2,
|
||||
marginBottom: 8,
|
||||
marginTop: 2,
|
||||
overflow: 'visible',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 1,
|
||||
},
|
||||
completeRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 1,
|
||||
marginBottom: 8,
|
||||
position: 'relative',
|
||||
right: 0,
|
||||
opacity: 0.8,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,9 +3,7 @@ import { StyleSheet, FlatList, Text, View, FlatListProps } from 'react-native';
|
||||
import { Portal, Modal } from 'react-native-paper';
|
||||
import GlobalSearchNovelCover from '../globalsearch/GlobalSearchNovelCover';
|
||||
|
||||
import { migrateNovel } from '@services/migrate/migrateNovel';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
import { Button } from '@components';
|
||||
import { getString } from '@strings/translations';
|
||||
import { MigrateNovelScreenProps } from '@navigators/types';
|
||||
@@ -13,6 +11,7 @@ import { NovelInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { SourceSearchResult } from './MigrationNovels';
|
||||
import { NovelItem } from '@plugins/types';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
|
||||
interface MigrationNovelListProps {
|
||||
data: SourceSearchResult;
|
||||
@@ -105,7 +104,7 @@ const MigrationNovelList = ({
|
||||
}}
|
||||
>
|
||||
{getString('browseScreen.migration.dialogMessage', {
|
||||
url: selectedNovel.path,
|
||||
url: selectedNovel.name,
|
||||
})}
|
||||
</Text>
|
||||
<View
|
||||
@@ -121,9 +120,14 @@ const MigrationNovelList = ({
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideMigrateNovelDialog();
|
||||
migrateNovel(pluginId, fromNovel, selectedNovel.path).catch(
|
||||
error => showToast(error.message),
|
||||
);
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'MIGRATE_NOVEL',
|
||||
data: {
|
||||
pluginId,
|
||||
fromNovel,
|
||||
toNovelPath: selectedNovel.path,
|
||||
},
|
||||
});
|
||||
}}
|
||||
title={getString('novelScreen.migrate')}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@ const MigrationNovels = ({ navigation, route }: MigrateNovelScreenProps) => {
|
||||
? {
|
||||
...pluginItem,
|
||||
loading: false,
|
||||
error: e.message,
|
||||
error: e?.message,
|
||||
}
|
||||
: pluginItem,
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ThemeColors } from '@theme/types';
|
||||
import { coverPlaceholderColor } from '@theme/colors';
|
||||
import { getString } from '@strings/translations';
|
||||
import { HistoryScreenProps } from '@navigators/types';
|
||||
import { defaultCover } from '@plugins/helpers/constants';
|
||||
|
||||
interface HistoryCardProps {
|
||||
history: History;
|
||||
@@ -50,7 +51,10 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image source={{ uri: history.novelCover }} style={styles.cover} />
|
||||
<Image
|
||||
source={{ uri: history.novelCover || defaultCover }}
|
||||
style={styles.cover}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.detailsContainer}>
|
||||
<Text
|
||||
|
||||
@@ -37,7 +37,8 @@ import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/Source
|
||||
import { Row } from '@components/Common';
|
||||
import { LibraryScreenProps } from '@navigators/types';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { importEpub } from '@services/epub/import';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
|
||||
type State = NavigationState<{
|
||||
key: string;
|
||||
@@ -139,6 +140,18 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
setFalse: closeSetCategoryModal,
|
||||
} = useBoolean();
|
||||
|
||||
function openRandom() {
|
||||
const novels = library[index].novels;
|
||||
const randomNovel = novels[Math.floor(Math.random() * novels.length)];
|
||||
if (randomNovel) {
|
||||
navigation.navigate('Novel', {
|
||||
name: randomNovel.name,
|
||||
path: randomNovel.path,
|
||||
pluginId: randomNovel.pluginId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchbarV2
|
||||
@@ -164,16 +177,57 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
iconName: 'book-arrow-up-outline',
|
||||
onPress: importEpub,
|
||||
},
|
||||
{
|
||||
iconName: 'filter-variant',
|
||||
onPress: () => bottomSheetRef.current?.present(),
|
||||
},
|
||||
]
|
||||
}
|
||||
menuButtons={[
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateLibrary'),
|
||||
onPress: () =>
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateCategory'),
|
||||
onPress: () =>
|
||||
//2 = local category
|
||||
library[index].id !== 2 &&
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
data: library[index].id,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.importEpub'),
|
||||
onPress: () => {
|
||||
DocumentPicker.getDocumentAsync({
|
||||
type: 'application/epub+zip',
|
||||
copyToCacheDirectory: true,
|
||||
multiple: true,
|
||||
}).then(res => {
|
||||
if (!res.canceled) {
|
||||
ServiceManager.manager.addTask(
|
||||
res.assets.map(asset => ({
|
||||
name: 'IMPORT_EPUB',
|
||||
data: {
|
||||
filename: asset.name,
|
||||
uri: asset.uri,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.openRandom'),
|
||||
onPress: openRandom,
|
||||
},
|
||||
]}
|
||||
theme={theme}
|
||||
/>
|
||||
{downloadedOnlyMode ? (
|
||||
|
||||
@@ -10,9 +10,9 @@ import { LibraryNovelInfo } from '@database/types';
|
||||
|
||||
import { getString } from '@strings/translations';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { updateLibrary } from '@services/updates';
|
||||
import { LibraryScreenProps } from '@navigators/types';
|
||||
import { importEpub } from '@services/epub/import';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
|
||||
interface Props {
|
||||
categoryId: number;
|
||||
@@ -60,7 +60,10 @@ export const LibraryView: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
updateLibrary(categoryId);
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
data: categoryId,
|
||||
});
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
@@ -84,7 +87,25 @@ export const LibraryView: React.FC<Props> = ({
|
||||
: {
|
||||
iconName: 'book-arrow-up-outline',
|
||||
title: getString('advancedSettingsScreen.importEpub'),
|
||||
onPress: importEpub,
|
||||
onPress: () => {
|
||||
DocumentPicker.getDocumentAsync({
|
||||
type: 'application/epub+zip',
|
||||
copyToCacheDirectory: true,
|
||||
multiple: true,
|
||||
}).then(res => {
|
||||
if (!res.canceled) {
|
||||
ServiceManager.manager.addTask(
|
||||
res.assets.map(asset => ({
|
||||
name: 'IMPORT_EPUB',
|
||||
data: {
|
||||
filename: asset.name,
|
||||
uri: asset.uri,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,14 +6,30 @@ import * as Linking from 'expo-linking';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ScreenContainer } from '@components/Common';
|
||||
import { MoreHeader } from './components/MoreHeader';
|
||||
import { appVersion, releaseDate } from '@utils/versionUtils';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { List } from '@components';
|
||||
import { AboutScreenProps } from '@navigators/types';
|
||||
import { GIT_HASH, RELEASE_DATE, BUILD_TYPE } from '@env';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
function getBuildName() {
|
||||
if (!GIT_HASH || !RELEASE_DATE || !BUILD_TYPE) {
|
||||
return `Custom build ${version}`;
|
||||
} else {
|
||||
const localDateTime = isNaN(Number(RELEASE_DATE))
|
||||
? RELEASE_DATE
|
||||
: new Date(Number(RELEASE_DATE)).toLocaleString();
|
||||
if (BUILD_TYPE === 'Release') {
|
||||
return `${BUILD_TYPE} ${version} (${localDateTime})`;
|
||||
}
|
||||
return `${BUILD_TYPE} ${version} (${localDateTime}) Commit: ${GIT_HASH}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenContainer theme={theme}>
|
||||
<MoreHeader
|
||||
@@ -26,19 +42,28 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
<List.Section>
|
||||
<List.Item
|
||||
title={getString('aboutScreen.version')}
|
||||
description={`Stable ${appVersion} (${releaseDate})`}
|
||||
description={getBuildName()}
|
||||
theme={theme}
|
||||
onPress={() => {
|
||||
Clipboard.setStringAsync(getBuildName());
|
||||
}}
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('aboutScreen.whatsNew')}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
`https://github.com/LNReader/lnreader/releases/tag/v${appVersion}`,
|
||||
`https://github.com/LNReader/lnreader/releases/tag/v${version}`,
|
||||
)
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Divider theme={theme} />
|
||||
<List.Item
|
||||
title={getString('aboutScreen.website')}
|
||||
description="https://lnreader.github.io"
|
||||
onPress={() => Linking.openURL('https://lnreader.github.io')}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('aboutScreen.discord')}
|
||||
description="https://discord.gg/QdcWN4MD63"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FlatList, View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
FAB,
|
||||
@@ -14,21 +14,26 @@ import { showToast } from '../../utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar, EmptyView } from '@components';
|
||||
import { DownloadQueueScreenProps } from '@navigators/types';
|
||||
import { useMMKVString } from 'react-native-mmkv';
|
||||
import { BACKGROUND_ACTION, BackgoundAction } from '@services/constants';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const [currentAction] = useMMKVString(BACKGROUND_ACTION);
|
||||
const isDownloading = useMemo(
|
||||
() => currentAction === BackgoundAction.DOWNLOAD_CHAPTER,
|
||||
[currentAction],
|
||||
);
|
||||
const { queue, resumeDowndload, pauseDownload, cancelDownload } =
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const { downloadQueue, resumeDowndload, pauseDownload, cancelDownload } =
|
||||
useDownload();
|
||||
const [isDownloading, setIsDownloading] = useState(
|
||||
ServiceManager.manager.isRunning,
|
||||
);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const openMenu = () => setVisible(true);
|
||||
const closeMenu = () => setVisible(false);
|
||||
useEffect(() => {
|
||||
if (downloadQueue.length === 0) {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [downloadQueue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Appbar
|
||||
@@ -40,7 +45,7 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
visible={visible}
|
||||
onDismiss={closeMenu}
|
||||
anchor={
|
||||
queue.length ? (
|
||||
downloadQueue.length ? (
|
||||
<MaterialAppbar.Action
|
||||
icon="dots-vertical"
|
||||
iconColor={theme.onSurface}
|
||||
@@ -53,6 +58,7 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
cancelDownload();
|
||||
setIsDownloading(false);
|
||||
showToast(getString('downloadScreen.cancelled'));
|
||||
closeMenu();
|
||||
}}
|
||||
@@ -63,18 +69,18 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
</Appbar>
|
||||
<FlatList
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 100 }}
|
||||
keyExtractor={item =>
|
||||
item.novel.pluginId + '_' + item.chapter.path.toString()
|
||||
}
|
||||
data={queue}
|
||||
keyExtractor={item => 'download_chapter_' + item.data.chapterId}
|
||||
data={downloadQueue}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ color: theme.onSurface }}>{item.chapter.name}</Text>
|
||||
<Text style={{ color: theme.onSurface }}>
|
||||
{item.data.chapterName}
|
||||
</Text>
|
||||
<ProgressBar
|
||||
indeterminate={
|
||||
isDownloading &&
|
||||
queue.length > 0 &&
|
||||
queue[0].chapter.id === item.chapter.id
|
||||
downloadQueue.length > 0 &&
|
||||
downloadQueue[0].data.chapterId === item.data.chapterId
|
||||
}
|
||||
color={theme.primary}
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -89,9 +95,9 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{queue.length > 0 ? (
|
||||
{downloadQueue.length > 0 ? (
|
||||
<FAB
|
||||
style={[styles.fab, { backgroundColor: theme.primary }]}
|
||||
style={[styles.fab, { backgroundColor: theme.primary, bottom }]}
|
||||
color={theme.onPrimary}
|
||||
label={
|
||||
isDownloading
|
||||
@@ -101,7 +107,13 @@ const DownloadQueue = ({ navigation }: DownloadQueueScreenProps) => {
|
||||
uppercase={false}
|
||||
icon={isDownloading ? 'pause' : 'play'}
|
||||
onPress={() => {
|
||||
isDownloading ? pauseDownload() : resumeDowndload();
|
||||
if (isDownloading) {
|
||||
pauseDownload();
|
||||
setIsDownloading(false);
|
||||
} else {
|
||||
resumeDowndload();
|
||||
setIsDownloading(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Switch from '@components/Switch/Switch';
|
||||
|
||||
const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { queue } = useDownload();
|
||||
const { downloadQueue } = useDownload();
|
||||
const {
|
||||
incognitoMode = false,
|
||||
downloadedOnlyMode = false,
|
||||
@@ -27,7 +27,8 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
return (
|
||||
<ScrollView>
|
||||
<MoreHeader
|
||||
title={getString('more')}
|
||||
// status bar is translucent, text could be mess with it
|
||||
title={''}
|
||||
navigation={navigation}
|
||||
theme={theme}
|
||||
/>
|
||||
@@ -107,7 +108,9 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
<List.Divider theme={theme} />
|
||||
<List.Item
|
||||
title={getString('moreScreen.downloadQueue')}
|
||||
description={queue.length > 0 ? queue.length + ' remaining' : ''}
|
||||
description={
|
||||
downloadQueue.length > 0 ? downloadQueue.length + ' remaining' : ''
|
||||
}
|
||||
icon="progress-download"
|
||||
onPress={() =>
|
||||
navigation.navigate('MoreStack', {
|
||||
|
||||
@@ -4,23 +4,20 @@ import {
|
||||
View,
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
Share,
|
||||
Text,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
Share,
|
||||
} from 'react-native';
|
||||
import { Drawer } from 'react-native-drawer-layout';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import {
|
||||
Portal,
|
||||
Appbar,
|
||||
IconButton,
|
||||
Menu,
|
||||
Snackbar,
|
||||
AnimatedFAB,
|
||||
} from 'react-native-paper';
|
||||
import { Portal, Appbar, Snackbar, AnimatedFAB } from 'react-native-paper';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { showToast } from '../../utils/showToast';
|
||||
import {
|
||||
@@ -32,7 +29,6 @@ import {
|
||||
import NovelInfoHeader from './components/Info/NovelInfoHeader';
|
||||
import NovelBottomSheet from './components/NovelBottomSheet';
|
||||
import TrackSheet from './components/Tracker/TrackSheet';
|
||||
import { Row } from '../../components/Common';
|
||||
import JumpToChapterModal from './components/JumpToChapterModal';
|
||||
import { Actionbar } from '../../components/Actionbar/Actionbar';
|
||||
import EditInfoModal from './components/EditInfoModal';
|
||||
@@ -46,8 +42,13 @@ import { ChapterInfo } from '@database/types';
|
||||
import ChapterItem from './components/ChapterItem';
|
||||
import { getString } from '@strings/translations';
|
||||
import NovelDrawer from './components/NovelDrawer';
|
||||
import { updateNovel } from '@services/updates/LibraryUpdateQueries';
|
||||
import {
|
||||
updateNovel,
|
||||
updateNovelPage,
|
||||
} from '@services/updates/LibraryUpdateQueries';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import NovelAppbar from './components/NovelAppbar';
|
||||
import { resolveUrl } from '@services/plugin/fetch';
|
||||
|
||||
const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
@@ -90,15 +91,9 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
queue: downloadQueue,
|
||||
downloadChapter,
|
||||
downloadChapters,
|
||||
} = useDownload();
|
||||
const { downloadQueue, downloadChapter, downloadChapters } = useDownload();
|
||||
|
||||
const [selected, setSelected] = useState<ChapterInfo[]>([]);
|
||||
const [downloadMenu, showDownloadMenu] = useState(false);
|
||||
const [extraMenu, showExtraMenu] = useState(false);
|
||||
const [editInfoModal, showEditInfoModal] = useState(false);
|
||||
const [isFabExtended, setIsFabExtended] = useState(true);
|
||||
|
||||
@@ -107,6 +102,8 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
let trackerSheetRef = useRef(null);
|
||||
|
||||
const deleteDownloadsSnackbar = useBoolean();
|
||||
|
||||
const headerOpacity = useSharedValue(0);
|
||||
const {
|
||||
value: drawerOpen,
|
||||
setTrue: openDrawer,
|
||||
@@ -115,6 +112,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
|
||||
const onPageScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y;
|
||||
headerOpacity.value = y < 50 ? 0 : (y - 50) / 150;
|
||||
const currentScrollPosition = Math.floor(y) ?? 0;
|
||||
if (useFabForContinueReading && lastRead) {
|
||||
setIsFabExtended(currentScrollPosition <= 0);
|
||||
@@ -135,7 +133,25 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
refreshNovelMetadata,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => showToast(getString('novelScreen.updatedToast', { name })))
|
||||
.then(() =>
|
||||
showToast(
|
||||
getString('novelScreen.updatedToast', { name: novel.name }),
|
||||
),
|
||||
)
|
||||
.catch(error => showToast(error.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
};
|
||||
|
||||
const onRefreshPage = (page: string) => {
|
||||
if (novel) {
|
||||
setUpdating(true);
|
||||
updateNovelPage(pluginId, novel.path, novel.id, page, {
|
||||
downloadNewChapters,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => showToast(`Updated page: ${page}`))
|
||||
.catch(e => showToast(e.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
};
|
||||
@@ -150,6 +166,33 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
/>
|
||||
);
|
||||
|
||||
const downloadChs = (amount: number | 'all' | 'unread') => {
|
||||
if (!novel) {
|
||||
return;
|
||||
}
|
||||
let filtered = chapters.filter(chapter => !chapter.isDownloaded);
|
||||
if (amount === 'unread') {
|
||||
filtered = filtered.filter(chapter => chapter.unread);
|
||||
}
|
||||
if (isNumber(amount)) {
|
||||
filtered = filtered.slice(0, amount);
|
||||
}
|
||||
if (filtered) {
|
||||
downloadChapters(novel, filtered);
|
||||
}
|
||||
};
|
||||
const deleteChs = () => {
|
||||
deleteChapters(chapters.filter(c => c.isDownloaded));
|
||||
};
|
||||
const shareNovel = () => {
|
||||
if (!novel) {
|
||||
return;
|
||||
}
|
||||
Share.share({
|
||||
message: resolveUrl(novel.pluginId, novel.path, true),
|
||||
});
|
||||
};
|
||||
|
||||
const [jumpToChapterModal, showJumpToChapterModal] = useState(false);
|
||||
const downloadCustomChapterModal = useBoolean();
|
||||
|
||||
@@ -299,7 +342,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
};
|
||||
|
||||
const setCustomNovelCover = async () => {
|
||||
showExtraMenu(false);
|
||||
const newCover = await pickCustomNovelCover(novel);
|
||||
if (newCover) {
|
||||
setNovel({
|
||||
@@ -332,219 +374,19 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Portal>
|
||||
{selected.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: (StatusBar.currentHeight || 0) + 54,
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
iconColor={theme.onBackground}
|
||||
size={24}
|
||||
style={{ marginTop: (StatusBar.currentHeight || 0) + 8 }}
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
<Row>
|
||||
{!novel.isLocal ? (
|
||||
<IconButton
|
||||
icon="share-variant"
|
||||
iconColor={theme.onBackground}
|
||||
size={21}
|
||||
style={{
|
||||
marginTop: (StatusBar.currentHeight || 0) + 8,
|
||||
}}
|
||||
onPress={() =>
|
||||
Share.share({
|
||||
message: resolveUrl(novel.pluginId, novel.path, true),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
icon="text-box-search-outline"
|
||||
iconColor={theme.onBackground}
|
||||
size={21}
|
||||
style={{
|
||||
marginTop: (StatusBar.currentHeight || 0) + 8,
|
||||
}}
|
||||
onPress={() => showJumpToChapterModal(true)}
|
||||
/>
|
||||
{!novel.isLocal ? (
|
||||
<Menu
|
||||
visible={downloadMenu}
|
||||
onDismiss={() => showDownloadMenu(false)}
|
||||
anchor={
|
||||
<IconButton
|
||||
icon="download-outline"
|
||||
iconColor={theme.onBackground}
|
||||
size={24}
|
||||
style={{
|
||||
marginTop: (StatusBar.currentHeight || 0) + 8,
|
||||
}}
|
||||
onPress={() => showDownloadMenu(true)}
|
||||
/>
|
||||
}
|
||||
contentStyle={{ backgroundColor: theme.surface2 }}
|
||||
>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
const finded = chapters.find(
|
||||
chapter => chapter.unread && !chapter.isDownloaded,
|
||||
);
|
||||
if (novel && finded) {
|
||||
downloadChapter(novel, finded);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next5')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
if (novel) {
|
||||
downloadChapters(
|
||||
novel,
|
||||
chapters
|
||||
.filter(
|
||||
chapter =>
|
||||
chapter.unread && !chapter.isDownloaded,
|
||||
)
|
||||
.slice(0, 5),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next10')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
if (novel) {
|
||||
downloadChapters(
|
||||
novel,
|
||||
chapters
|
||||
.filter(
|
||||
chapter =>
|
||||
chapter.unread && !chapter.isDownloaded,
|
||||
)
|
||||
.slice(0, 10),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.custom')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
onPress={() => {
|
||||
downloadCustomChapterModal.setTrue();
|
||||
showDownloadMenu(false);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.unread')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
if (novel) {
|
||||
downloadChapters(
|
||||
novel,
|
||||
chapters.filter(
|
||||
chapter =>
|
||||
chapter.unread && !chapter.isDownloaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('common.all')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
if (novel) {
|
||||
downloadChapters(
|
||||
novel,
|
||||
chapters.filter(chapter => !chapter.isDownloaded),
|
||||
);
|
||||
}
|
||||
showDownloadMenu(false);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.delete')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
deleteChapters(chapters.filter(c => c.isDownloaded));
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
) : null}
|
||||
|
||||
<Menu
|
||||
visible={extraMenu}
|
||||
onDismiss={() => showExtraMenu(false)}
|
||||
anchor={
|
||||
<IconButton
|
||||
icon="dots-vertical"
|
||||
iconColor={theme.onBackground}
|
||||
size={21}
|
||||
style={{
|
||||
marginTop: (StatusBar.currentHeight || 0) + 8,
|
||||
marginRight: 16,
|
||||
}}
|
||||
onPress={() => showExtraMenu(true)}
|
||||
/>
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.surface2,
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.edit.info')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showEditInfoModal(true);
|
||||
showExtraMenu(false);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.edit.cover')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={setCustomNovelCover}
|
||||
/>
|
||||
</Menu>
|
||||
</Row>
|
||||
</View>
|
||||
<NovelAppbar
|
||||
deleteChapters={deleteChs}
|
||||
downloadChapters={downloadChs}
|
||||
showEditInfoModal={showEditInfoModal}
|
||||
setCustomNovelCover={setCustomNovelCover}
|
||||
downloadCustomChapterModal={downloadCustomChapterModal.setTrue}
|
||||
showJumpToChapterModal={showJumpToChapterModal}
|
||||
shareNovel={shareNovel}
|
||||
theme={theme}
|
||||
isLocal={novel.isLocal}
|
||||
goBack={navigation.goBack}
|
||||
headerOpacity={headerOpacity}
|
||||
/>
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
@@ -589,7 +431,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
renderItem={({ item }) => (
|
||||
<ChapterItem
|
||||
isDownloading={downloadQueue.some(
|
||||
c => c.chapter.id === item.id,
|
||||
c => c.data.chapterId === item.id,
|
||||
)}
|
||||
isLocal={novel.isLocal}
|
||||
theme={theme}
|
||||
@@ -621,6 +463,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
novelBottomSheetRef={novelBottomSheetRef}
|
||||
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
|
||||
page={pages.length > 1 ? pages[pageIndex] : undefined}
|
||||
onRefreshPage={onRefreshPage}
|
||||
openDrawer={openDrawer}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -83,7 +83,13 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
{novelName}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.row}>
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{unread ? (
|
||||
<MaterialCommunityIcons
|
||||
name="circle"
|
||||
@@ -113,7 +119,12 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.textRow}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{releaseTime && !isUpdateCard ? (
|
||||
<Text
|
||||
style={[
|
||||
@@ -123,6 +134,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
: bookmark
|
||||
? theme.primary
|
||||
: theme.onSurfaceVariant,
|
||||
marginTop: 4,
|
||||
},
|
||||
styles.text,
|
||||
]}
|
||||
@@ -137,6 +149,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
color: theme.outline,
|
||||
fontSize: 12,
|
||||
marginLeft: chapter.releaseTime ? 5 : 0,
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -179,7 +192,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
textRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 5,
|
||||
},
|
||||
row: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
unreadIcon: {
|
||||
|
||||
@@ -112,48 +112,47 @@ const EditInfoModal = ({
|
||||
placeholder={getString('novelScreen.edit.title', {
|
||||
title: novel.name,
|
||||
})}
|
||||
style={{ fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, name: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={getString('novelScreen.edit.author', {
|
||||
author: novel.author,
|
||||
})}
|
||||
style={{ fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, author: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={'Artist: ' + novel.artist}
|
||||
style={{ fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
theme={{ colors: { ...theme } }}
|
||||
onChangeText={text => setNovel({ ...novel, artist: text })}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={getString('novelScreen.edit.summary', {
|
||||
summary: novel.summary?.substring(0, 16),
|
||||
})}
|
||||
style={{ fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
onChangeText={text => setNovel({ ...novel, summary: text })}
|
||||
theme={{ colors: { ...theme } }}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder={getString('novelScreen.edit.addTag')}
|
||||
style={{ fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
mode="outlined"
|
||||
onChangeText={text => setTag(text)}
|
||||
@@ -163,6 +162,7 @@ const EditInfoModal = ({
|
||||
}}
|
||||
theme={{ colors: { ...theme } }}
|
||||
dense
|
||||
style={styles.inputWrapper}
|
||||
/>
|
||||
|
||||
{novel.genres !== undefined && novel.genres !== '' ? (
|
||||
@@ -213,11 +213,11 @@ const GenreChip = ({
|
||||
borderRadius: 8,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 16,
|
||||
marginVertical: 4,
|
||||
marginBottom: 4,
|
||||
marginRight: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.outline,
|
||||
backgroundColor: theme.secondaryContainer,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -242,16 +242,19 @@ const GenreChip = ({
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
margin: 30,
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
padding: 24,
|
||||
borderRadius: 28,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontSize: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: '#FF0033',
|
||||
paddingTop: 8,
|
||||
},
|
||||
genreChip: {},
|
||||
inputWrapper: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -298,6 +298,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
novelInfo: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
followButton: {
|
||||
height: 32,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import color from 'color';
|
||||
|
||||
@@ -29,8 +29,10 @@ import { NovelScreenProps } from '@navigators/types';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
import { UseBooleanReturnType } from '@hooks';
|
||||
import { useAppSettings } from '@hooks/persisted';
|
||||
import { NovelStatus } from '@plugins/types';
|
||||
import { NovelStatus, PluginItem } from '@plugins/types';
|
||||
import { translateNovelStatus } from '@utils/translateEnum';
|
||||
import { getMMKVObject } from '@utils/mmkv/mmkv';
|
||||
import { AVAILABLE_PLUGINS } from '@hooks/persisted/usePlugins';
|
||||
|
||||
interface NovelInfoHeaderProps {
|
||||
novel: NovelData;
|
||||
@@ -47,8 +49,19 @@ interface NovelInfoHeaderProps {
|
||||
deleteDownloadsSnackbar: UseBooleanReturnType;
|
||||
page?: string;
|
||||
openDrawer: () => void;
|
||||
onRefreshPage: (page: string) => void;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
if (status === NovelStatus.Ongoing) {
|
||||
return 'clock-outline';
|
||||
}
|
||||
if (status === NovelStatus.Completed) {
|
||||
return 'check-all';
|
||||
}
|
||||
return 'help';
|
||||
};
|
||||
|
||||
const NovelInfoHeader = ({
|
||||
novel,
|
||||
theme,
|
||||
@@ -64,18 +77,18 @@ const NovelInfoHeader = ({
|
||||
deleteDownloadsSnackbar,
|
||||
page,
|
||||
openDrawer,
|
||||
onRefreshPage,
|
||||
}: NovelInfoHeaderProps) => {
|
||||
const { hideBackdrop = false } = useAppSettings();
|
||||
|
||||
const getStatusIcon = useCallback((status?: string) => {
|
||||
if (status === NovelStatus.Ongoing) {
|
||||
return 'clock-outline';
|
||||
}
|
||||
if (status === NovelStatus.Completed) {
|
||||
return 'check-all';
|
||||
}
|
||||
return 'help';
|
||||
}, []);
|
||||
const pluginName = useMemo(
|
||||
() =>
|
||||
(getMMKVObject<PluginItem[]>(AVAILABLE_PLUGINS) || []).find(
|
||||
plugin => plugin.id === novel.pluginId,
|
||||
)?.name || novel.pluginId,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CoverImage
|
||||
@@ -144,7 +157,7 @@ const NovelInfoHeader = ({
|
||||
{(translateNovelStatus(novel.status) ||
|
||||
getString('novelScreen.unknownStatus')) +
|
||||
' • ' +
|
||||
novel.pluginId}
|
||||
pluginName}
|
||||
</NovelInfo>
|
||||
</Row>
|
||||
</View>
|
||||
@@ -200,6 +213,14 @@ const NovelInfoHeader = ({
|
||||
{`${chapters?.length} ${getString('novelScreen.chapters')}`}
|
||||
</Text>
|
||||
</View>
|
||||
{page && Number(page) ? (
|
||||
<IconButton
|
||||
icon="reload"
|
||||
iconColor={theme.onSurface}
|
||||
size={24}
|
||||
onPress={() => onRefreshPage(page)}
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
icon="filter-variant"
|
||||
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar, Menu } from 'react-native-paper';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import Animated, {
|
||||
SharedValue,
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const NovelAppbar = ({
|
||||
theme,
|
||||
isLocal,
|
||||
downloadChapters,
|
||||
deleteChapters,
|
||||
showEditInfoModal,
|
||||
downloadCustomChapterModal,
|
||||
setCustomNovelCover,
|
||||
goBack,
|
||||
shareNovel,
|
||||
showJumpToChapterModal,
|
||||
headerOpacity,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
isLocal: boolean;
|
||||
downloadChapters: (amount: number | 'all' | 'unread') => void;
|
||||
deleteChapters: () => void;
|
||||
showEditInfoModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
downloadCustomChapterModal: () => void;
|
||||
setCustomNovelCover: () => Promise<void>;
|
||||
goBack: () => void;
|
||||
shareNovel: () => void;
|
||||
showJumpToChapterModal: (arg: boolean) => void;
|
||||
headerOpacity: SharedValue<number>;
|
||||
}) => {
|
||||
const headerOpacityStyle = useAnimatedStyle(() => {
|
||||
const backgroundColor = interpolateColor(
|
||||
headerOpacity.value,
|
||||
[0, 1],
|
||||
['transparent', theme.surface2],
|
||||
);
|
||||
return {
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
const [downloadMenu, showDownloadMenu] = useState(false);
|
||||
const [extraMenu, showExtraMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<Animated.View style={[headerOpacityStyle]}>
|
||||
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
|
||||
<Appbar.BackAction onPress={goBack} />
|
||||
<Appbar.Content title="" />
|
||||
<Appbar.Action icon="share-variant" onPress={shareNovel} />
|
||||
<Appbar.Action
|
||||
icon="text-box-search-outline"
|
||||
onPress={() => {
|
||||
showJumpToChapterModal(true);
|
||||
}}
|
||||
/>
|
||||
{!isLocal && (
|
||||
<Menu
|
||||
visible={downloadMenu}
|
||||
onDismiss={() => showDownloadMenu(false)}
|
||||
anchor={
|
||||
<Appbar.Action
|
||||
icon="download-outline"
|
||||
onPress={() => showDownloadMenu(true)}
|
||||
theme={{ colors: theme }}
|
||||
style={{ paddingTop: 2 }}
|
||||
size={27}
|
||||
/>
|
||||
}
|
||||
contentStyle={{ backgroundColor: theme.surface2 }}
|
||||
>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
downloadChapters(1);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next5')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
downloadChapters(5);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.next10')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
downloadChapters(10);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.custom')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
onPress={() => {
|
||||
downloadCustomChapterModal();
|
||||
showDownloadMenu(false);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.unread')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
downloadChapters('unread');
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('common.all')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
downloadChapters('all');
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.download.delete')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showDownloadMenu(false);
|
||||
deleteChapters();
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
<Menu
|
||||
visible={extraMenu}
|
||||
onDismiss={() => showExtraMenu(false)}
|
||||
anchor={
|
||||
<Appbar.Action
|
||||
icon="dots-vertical"
|
||||
onPress={() => showExtraMenu(true)}
|
||||
theme={{ colors: theme }}
|
||||
/>
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.surface2,
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.edit.info')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showEditInfoModal(true);
|
||||
showExtraMenu(false);
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
title={getString('novelScreen.edit.cover')}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{
|
||||
color: theme.onSurface,
|
||||
}}
|
||||
onPress={() => {
|
||||
showExtraMenu(false);
|
||||
setCustomNovelCover;
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</Appbar.Header>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default NovelAppbar;
|
||||
@@ -34,7 +34,7 @@ const NovelScreenButtonGroup: React.FC<NovelScreenButtonGroupProps> = ({
|
||||
|
||||
const handleOpenWebView = async () => {
|
||||
navigate('WebviewScreen', {
|
||||
name: novel.pluginId,
|
||||
name: novel.name,
|
||||
url: novel.path,
|
||||
pluginId: novel.pluginId,
|
||||
isNovel: true,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
import { Button } from '@components';
|
||||
import PickThemeStep from './PickThemeStep';
|
||||
import { useState } from 'react';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
|
||||
enum OnboardingStep {
|
||||
PICK_THEME,
|
||||
}
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const theme = useTheme();
|
||||
const [step] = useState<OnboardingStep>(OnboardingStep.PICK_THEME);
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case OnboardingStep.PICK_THEME:
|
||||
return <PickThemeStep />;
|
||||
default:
|
||||
return <PickThemeStep />;
|
||||
}
|
||||
};
|
||||
const renderHelptext = () => {
|
||||
switch (step) {
|
||||
case OnboardingStep.PICK_THEME:
|
||||
return 'Pick a theme';
|
||||
default:
|
||||
return <PickThemeStep />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.root, { backgroundColor: theme.background }]}>
|
||||
<Image
|
||||
source={require('../../../assets/logo.png')}
|
||||
tintColor={theme.primary}
|
||||
style={{
|
||||
width: 90,
|
||||
height: 90,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
variant="headlineLarge"
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
paddingBottom: 8,
|
||||
color: theme.onBackground,
|
||||
}}
|
||||
>
|
||||
Yaholo!
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
paddingBottom: 8,
|
||||
color: theme.onBackground,
|
||||
}}
|
||||
>
|
||||
{renderHelptext()}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.stepContainer,
|
||||
{ backgroundColor: theme.surfaceVariant },
|
||||
]}
|
||||
>
|
||||
{renderStep()}
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
title="Complete"
|
||||
mode="contained"
|
||||
onPress={() => {
|
||||
MMKVStorage.set('IS_ONBOARDED', true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
stepContainer: {
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { ThemePicker } from '@components/ThemePicker/ThemePicker';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { darkThemes, lightThemes } from '@theme/md3';
|
||||
|
||||
const ThemeList = ({
|
||||
theme,
|
||||
list,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
list: ThemeColors[];
|
||||
}) => {
|
||||
const [, setTheme] = useMMKVObject('APP_THEME');
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{list.map(item => (
|
||||
<ThemePicker
|
||||
key={item.id}
|
||||
currentTheme={theme}
|
||||
theme={item}
|
||||
onPress={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const SchemeButton = ({
|
||||
onPress,
|
||||
isDarkButton = false,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
isDarkButton?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSelected =
|
||||
(theme.isDark && isDarkButton) || (!theme.isDark && !isDarkButton);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.schemeButtonWrapper,
|
||||
isDarkButton ? styles.darkButton : styles.lightButton,
|
||||
{
|
||||
borderColor: theme.onSurface,
|
||||
backgroundColor: isSelected ? theme.primary : theme.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor, borderless: true }}
|
||||
style={styles.schemeButton}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={{ color: isSelected ? theme.onPrimary : theme.onSurface }}>
|
||||
{isDarkButton ? 'Dark' : 'Light'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getInverseTheme = (theme: ThemeColors) => {
|
||||
let index = (theme.isDark ? darkThemes : lightThemes).findIndex(
|
||||
item => item.id === theme.id,
|
||||
);
|
||||
if (index === -1) {
|
||||
index = 1;
|
||||
}
|
||||
if (theme.isDark) {
|
||||
return lightThemes[index];
|
||||
}
|
||||
return darkThemes[index];
|
||||
};
|
||||
|
||||
export default function PickThemeStep() {
|
||||
const theme = useTheme();
|
||||
const [, setTheme] = useMMKVObject('APP_THEME');
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.buttonContainer}>
|
||||
<SchemeButton
|
||||
onPress={() => {
|
||||
if (theme.isDark) {
|
||||
setTheme(getInverseTheme(theme));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SchemeButton
|
||||
onPress={() => {
|
||||
if (!theme.isDark) {
|
||||
setTheme(getInverseTheme(theme));
|
||||
}
|
||||
}}
|
||||
isDarkButton={true}
|
||||
/>
|
||||
</View>
|
||||
<ThemeList theme={theme} list={theme.isDark ? darkThemes : lightThemes} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 24,
|
||||
},
|
||||
schemeButtonWrapper: {
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
schemeButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 36,
|
||||
},
|
||||
lightButton: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderBottomLeftRadius: 24,
|
||||
},
|
||||
darkButton: {
|
||||
borderRightWidth: 1,
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
NativeEventEmitter,
|
||||
DrawerLayoutAndroid,
|
||||
} from 'react-native';
|
||||
import * as RNFS from 'react-native-fs';
|
||||
import * as Speech from 'expo-speech';
|
||||
|
||||
import VolumeButtonListener from '@native/volumeButtonListener';
|
||||
|
||||
@@ -48,10 +48,10 @@ import ChapterLoadingScreen from './ChapterLoadingScreen/ChapterLoadingScreen';
|
||||
import { ErrorScreenV2 } from '@components';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import WebView, { WebViewNavigation } from 'react-native-webview';
|
||||
import { NovelDownloadFolder } from '@utils/constants/download';
|
||||
import WebView from 'react-native-webview';
|
||||
import { getString } from '@strings/translations';
|
||||
import TextFile from '@native/TextFile';
|
||||
import FileManager from '@native/FileManager';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
|
||||
const Chapter = ({ route, navigation }: ChapterScreenProps) => {
|
||||
const drawerRef = useRef<DrawerLayoutAndroid>(null);
|
||||
@@ -132,15 +132,16 @@ export const ChapterContent = ({
|
||||
VolumeButtonListener.preventDefault();
|
||||
emmiter.current.addListener('VolumeUp', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top:${-Dimensions.get('window')
|
||||
.height},behavior:'smooth',})
|
||||
window.scrollBy({top: -${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
emmiter.current.addListener('VolumeDown', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top:${
|
||||
Dimensions.get('window').height
|
||||
},behavior:'smooth',})
|
||||
window.scrollBy({top: ${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
};
|
||||
@@ -158,6 +159,7 @@ export const ChapterContent = ({
|
||||
VolumeButtonListener.disconnect();
|
||||
emmiter.current.removeAllListeners('VolumeUp');
|
||||
emmiter.current.removeAllListeners('VolumeDown');
|
||||
Speech.stop();
|
||||
};
|
||||
}, [useVolumeButtons, chapter]);
|
||||
|
||||
@@ -167,9 +169,9 @@ export const ChapterContent = ({
|
||||
|
||||
const getChapter = async () => {
|
||||
try {
|
||||
const filePath = `${NovelDownloadFolder}/${novel.pluginId}/${chapter.novelId}/${chapter.id}/index.html`;
|
||||
if (await RNFS.exists(filePath)) {
|
||||
sourceChapter.chapterText = await TextFile.readFile(filePath);
|
||||
const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${chapter.novelId}/${chapter.id}/index.html`;
|
||||
if (await FileManager.exists(filePath)) {
|
||||
sourceChapter.chapterText = FileManager.readFile(filePath);
|
||||
} else {
|
||||
await fetchChapter(novel.pluginId, chapter.path)
|
||||
.then(res => {
|
||||
@@ -280,19 +282,6 @@ export const ChapterContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onWebViewNavigationStateChange = async ({ url }: WebViewNavigation) => {
|
||||
if (url !== 'about:blank') {
|
||||
setLoading(true);
|
||||
fetchChapter(novel.pluginId, chapter.path)
|
||||
.then(res => {
|
||||
sourceChapter.chapterText = res;
|
||||
setChapter(sourceChapter);
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
const chapterText = useMemo(
|
||||
() =>
|
||||
sanitizeChapterText(sourceChapter.chapterText, {
|
||||
@@ -303,8 +292,8 @@ export const ChapterContent = ({
|
||||
|
||||
const openDrawer = useCallback(() => {
|
||||
drawerRef.current?.openDrawer();
|
||||
setHidden(true);
|
||||
}, [drawerRef]);
|
||||
hideHeader();
|
||||
}, [drawerRef, hideHeader]);
|
||||
|
||||
if (loading) {
|
||||
return <ChapterLoadingScreen />;
|
||||
@@ -329,7 +318,7 @@ export const ChapterContent = ({
|
||||
title: 'WebView',
|
||||
onPress: () =>
|
||||
navigation.navigate('WebviewScreen', {
|
||||
name: `${chapter.name} | ${novel.name}`,
|
||||
name: novel.name,
|
||||
url: chapter.path,
|
||||
pluginId: novel.pluginId,
|
||||
}),
|
||||
@@ -338,7 +327,6 @@ export const ChapterContent = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewReader
|
||||
@@ -352,7 +340,6 @@ export const ChapterContent = ({
|
||||
}}
|
||||
onPress={hideHeader}
|
||||
navigateToChapterBySwipe={navigateToChapterBySwipe}
|
||||
onWebViewNavigationStateChange={onWebViewNavigationStateChange}
|
||||
/>
|
||||
<ReaderBottomSheetV2 bottomSheetRef={readerSheetRef} />
|
||||
{!hidden ? (
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { StyleSheet, View, Pressable } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import color from 'color';
|
||||
import { useAppSettings, useTheme } from '@hooks/persisted';
|
||||
import { FlashList, FlashListProps } from '@shopify/flash-list';
|
||||
import { Button, LoadingScreenV2 } from '@components/index';
|
||||
import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelSettings } from '@hooks/persisted/useNovel';
|
||||
|
||||
type ChapterDrawerProps = ChapterScreenProps & {
|
||||
chapters: ChapterInfo[];
|
||||
novelSettings: NovelSettings;
|
||||
pages: string[];
|
||||
setPageIndex: (value: number) => void;
|
||||
};
|
||||
|
||||
const ChapterDrawer = ({
|
||||
route,
|
||||
navigation,
|
||||
chapters,
|
||||
novelSettings,
|
||||
pages,
|
||||
setPageIndex,
|
||||
}: ChapterDrawerProps) => {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = createStylesheet(theme, insets);
|
||||
const listRef = useRef<FlashList<ChapterInfo>>(null);
|
||||
const { chapter, novel: novelItem } = route.params;
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
const { sort = defaultChapterSort } = novelSettings;
|
||||
|
||||
const listAscending = sort === 'ORDER BY position ASC';
|
||||
const scrollToIndex = useMemo(() => {
|
||||
if (chapters.length < 1) {
|
||||
return 0;
|
||||
}
|
||||
const indexOfCurrentChapter =
|
||||
chapters.findIndex(el => {
|
||||
return el.id === chapter.id;
|
||||
}) || 0;
|
||||
let res = indexOfCurrentChapter >= 2 ? indexOfCurrentChapter - 2 : 0;
|
||||
return res;
|
||||
}, [chapters, chapter.id, listAscending]);
|
||||
|
||||
const [buttonProperties, setButtonProperties] = useState({
|
||||
up: {
|
||||
text: getString('readerScreen.drawer.scrollToTop'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToIndex({ index: 0, animated: true });
|
||||
},
|
||||
},
|
||||
down: {
|
||||
text: getString('readerScreen.drawer.scrollToBottom'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToEnd({
|
||||
animated: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const changeChapter = (item: ChapterInfo) => {
|
||||
navigation.replace('Chapter', {
|
||||
novel: novelItem,
|
||||
chapter: item,
|
||||
});
|
||||
};
|
||||
const renderItem: FlashListProps<ChapterInfo>['renderItem'] = ({ item }) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.drawerElementContainer,
|
||||
item.id === chapter.id && {
|
||||
backgroundColor: color(theme.primary).alpha(0.12).string(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
onPress={() => changeChapter(item)}
|
||||
style={styles.chapterCtn}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.chapterNameCtn,
|
||||
{ color: item.unread ? theme.onSurface : theme.outline },
|
||||
]}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.releaseTime ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.releaseDateCtn,
|
||||
{ color: item.unread ? theme.onSurfaceVariant : theme.outline },
|
||||
]}
|
||||
>
|
||||
{item.releaseTime}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ListFooter = () => {
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={styles.button}
|
||||
title={buttonProperties.up.text}
|
||||
onPress={buttonProperties.up.func}
|
||||
/>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={styles.button}
|
||||
title={buttonProperties.down.text}
|
||||
onPress={buttonProperties.down.func}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const checkViewableItems: FlashListProps<ChapterInfo>['onViewableItemsChanged'] =
|
||||
({ viewableItems }) => {
|
||||
let up = {
|
||||
text: getString('readerScreen.drawer.scrollToTop'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToIndex({ index: 0, animated: true });
|
||||
},
|
||||
};
|
||||
let down = {
|
||||
text: getString('readerScreen.drawer.scrollToBottom'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToEnd({
|
||||
animated: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
if (viewableItems.length !== 0) {
|
||||
let visible = viewableItems.find(({ item }) => {
|
||||
return item.id === chapter.id;
|
||||
});
|
||||
if (!visible) {
|
||||
if (
|
||||
listAscending
|
||||
? viewableItems[0].item.id < chapter.id
|
||||
: viewableItems[0].item.id > chapter.id
|
||||
) {
|
||||
down = {
|
||||
text: getString('readerScreen.drawer.scrollToCurrentChapter'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: scrollToIndex,
|
||||
animated: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
up = {
|
||||
text: getString('readerScreen.drawer.scrollToCurrentChapter'),
|
||||
func: () => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: scrollToIndex,
|
||||
animated: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
setButtonProperties({
|
||||
up: up,
|
||||
down: down,
|
||||
});
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
let pageIndex = pages.indexOf(chapter.page);
|
||||
if (pageIndex === -1) {
|
||||
pageIndex = 0;
|
||||
}
|
||||
setPageIndex(pageIndex);
|
||||
}, [chapter, pages]);
|
||||
return (
|
||||
<View style={styles.drawer}>
|
||||
<Text style={styles.headerCtn}>{getString('common.chapters')}</Text>
|
||||
{scrollToIndex !== undefined ? (
|
||||
<FlashList
|
||||
ref={listRef}
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
renderItem={renderItem}
|
||||
estimatedItemSize={60}
|
||||
initialScrollIndex={scrollToIndex}
|
||||
/>
|
||||
) : (
|
||||
<LoadingScreenV2 theme={theme} />
|
||||
)}
|
||||
<ListFooter />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStylesheet = (theme: ThemeColors, insets: EdgeInsets) => {
|
||||
return StyleSheet.create({
|
||||
drawer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.surface,
|
||||
paddingTop: 48,
|
||||
},
|
||||
headerCtn: {
|
||||
fontSize: 16,
|
||||
padding: 16,
|
||||
marginBottom: 4,
|
||||
fontWeight: 'bold',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.outline,
|
||||
color: theme.onSurface,
|
||||
},
|
||||
chapterCtn: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chapterNameCtn: {
|
||||
fontSize: 12,
|
||||
marginBottom: 2,
|
||||
color: theme.onSurface,
|
||||
},
|
||||
releaseDateCtn: {
|
||||
fontSize: 10,
|
||||
color: theme.onSurfaceVariant,
|
||||
},
|
||||
drawerElementContainer: {
|
||||
margin: 4,
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
borderRadius: 50,
|
||||
overflow: 'hidden',
|
||||
minHeight: 48,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: insets.bottom,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.outline,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ChapterDrawer;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { View, Pressable, TextStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import color from 'color';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '@navigators/types';
|
||||
import { StyleProp } from 'react-native';
|
||||
import { ViewStyle } from 'react-native';
|
||||
|
||||
type Styles = {
|
||||
chapterCtn: StyleProp<ViewStyle>;
|
||||
drawerElementContainer: StyleProp<ViewStyle>;
|
||||
chapterNameCtn: StyleProp<TextStyle>;
|
||||
releaseDateCtn: StyleProp<TextStyle>;
|
||||
};
|
||||
type Navigation = StackNavigationProp<RootStackParamList, 'Chapter', undefined>;
|
||||
|
||||
type Props = {
|
||||
item: ChapterInfo;
|
||||
novelItem: NovelInfo;
|
||||
styles: Styles;
|
||||
theme: ThemeColors;
|
||||
navigation: Navigation;
|
||||
chapterId: number;
|
||||
};
|
||||
const changeChapter = (
|
||||
item: ChapterInfo,
|
||||
navigation: Navigation,
|
||||
novelItem: NovelInfo,
|
||||
) => {
|
||||
navigation.replace('Chapter', {
|
||||
novel: novelItem,
|
||||
chapter: item,
|
||||
});
|
||||
};
|
||||
const renderListChapter = ({
|
||||
item,
|
||||
novelItem,
|
||||
styles,
|
||||
theme,
|
||||
navigation,
|
||||
chapterId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.drawerElementContainer,
|
||||
item.id === chapterId && {
|
||||
backgroundColor: color(theme.primary).alpha(0.12).string(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
onPress={() => changeChapter(item, navigation, novelItem)}
|
||||
style={styles.chapterCtn}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.chapterNameCtn,
|
||||
{ color: item.unread ? theme.onSurface : theme.outline },
|
||||
]}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.releaseTime ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.releaseDateCtn,
|
||||
{ color: item.unread ? theme.onSurfaceVariant : theme.outline },
|
||||
]}
|
||||
>
|
||||
{item.releaseTime}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default renderListChapter;
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { useAppSettings, useTheme } from '@hooks/persisted';
|
||||
import { FlashList, ViewToken } from '@shopify/flash-list';
|
||||
import { Button, LoadingScreenV2 } from '@components/index';
|
||||
import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { NovelSettings } from '@hooks/persisted/useNovel';
|
||||
import renderListChapter from './RenderListChapter';
|
||||
|
||||
type ChapterDrawerProps = ChapterScreenProps & {
|
||||
chapters: ChapterInfo[];
|
||||
novelSettings: NovelSettings;
|
||||
pages: string[];
|
||||
setPageIndex: (value: number) => void;
|
||||
};
|
||||
type ButtonProperties = {
|
||||
text: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
type ButtonsProperties = {
|
||||
up: ButtonProperties;
|
||||
down: ButtonProperties;
|
||||
};
|
||||
|
||||
const ChapterDrawer = ({
|
||||
route,
|
||||
navigation,
|
||||
chapters,
|
||||
novelSettings,
|
||||
pages,
|
||||
setPageIndex,
|
||||
}: ChapterDrawerProps) => {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
const listRef = useRef<FlashList<ChapterInfo>>(null);
|
||||
|
||||
const styles = createStylesheet(theme, insets);
|
||||
|
||||
const { chapter, novel: novelItem } = route.params;
|
||||
const { sort = defaultChapterSort } = novelSettings;
|
||||
const listAscending = sort === 'ORDER BY position ASC';
|
||||
|
||||
const defaultButtonLayout: ButtonsProperties = {
|
||||
up: {
|
||||
text: getString('readerScreen.drawer.scrollToTop'),
|
||||
index: 0,
|
||||
},
|
||||
down: {
|
||||
text: getString('readerScreen.drawer.scrollToBottom'),
|
||||
index: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let pageIndex = pages.indexOf(chapter.page);
|
||||
if (pageIndex === -1) {
|
||||
pageIndex = 0;
|
||||
}
|
||||
setPageIndex(pageIndex);
|
||||
}, [chapter, pages, setPageIndex]);
|
||||
|
||||
const scrollToIndex = useMemo(() => {
|
||||
if (chapters.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexOfCurrentChapter =
|
||||
chapters.findIndex(el => {
|
||||
return el.id === chapter.id;
|
||||
}) || 0;
|
||||
return indexOfCurrentChapter >= 2 ? indexOfCurrentChapter - 2 : 0;
|
||||
}, [chapters, chapter.id]);
|
||||
|
||||
const [footerBtnProps, setButtonProperties] =
|
||||
useState<ButtonsProperties>(defaultButtonLayout);
|
||||
|
||||
const checkViewableItems = ({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const curChapter = getString('readerScreen.drawer.scrollToCurrentChapter');
|
||||
let newBtnLayout = Object.create(defaultButtonLayout);
|
||||
|
||||
if (viewableItems.length !== 0) {
|
||||
let visible = viewableItems.find(({ item }) => {
|
||||
return item.id === chapter.id;
|
||||
});
|
||||
if (!visible && scrollToIndex) {
|
||||
if (
|
||||
listAscending
|
||||
? (viewableItems[0].index ?? 0) < scrollToIndex + 2
|
||||
: (viewableItems[0].index ?? 0) > scrollToIndex + 2
|
||||
) {
|
||||
newBtnLayout.down = {
|
||||
text: curChapter,
|
||||
index: scrollToIndex,
|
||||
};
|
||||
} else {
|
||||
newBtnLayout.up = {
|
||||
text: curChapter,
|
||||
index: scrollToIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setButtonProperties(newBtnLayout);
|
||||
}
|
||||
};
|
||||
const scroll = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
listRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
});
|
||||
} else {
|
||||
listRef.current?.scrollToEnd({
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.drawer}>
|
||||
<Text style={styles.headerCtn}>{getString('common.chapters')}</Text>
|
||||
{scrollToIndex === undefined ? (
|
||||
<LoadingScreenV2 theme={theme} />
|
||||
) : (
|
||||
<FlashList
|
||||
ref={listRef}
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
renderItem={val =>
|
||||
renderListChapter({
|
||||
item: val.item,
|
||||
novelItem,
|
||||
styles,
|
||||
theme,
|
||||
navigation,
|
||||
chapterId: chapter.id,
|
||||
})
|
||||
}
|
||||
estimatedItemSize={60}
|
||||
initialScrollIndex={scrollToIndex}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={styles.button}
|
||||
title={footerBtnProps.up.text}
|
||||
onPress={() => scroll(footerBtnProps.up.index)}
|
||||
/>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={styles.button}
|
||||
title={footerBtnProps.down.text}
|
||||
onPress={() => scroll(footerBtnProps.down.index)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStylesheet = (theme: ThemeColors, insets: EdgeInsets) => {
|
||||
return StyleSheet.create({
|
||||
drawer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.surface,
|
||||
paddingTop: 48,
|
||||
},
|
||||
headerCtn: {
|
||||
fontSize: 16,
|
||||
padding: 16,
|
||||
marginBottom: 4,
|
||||
fontWeight: 'bold',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.outline,
|
||||
color: theme.onSurface,
|
||||
},
|
||||
chapterCtn: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chapterNameCtn: {
|
||||
fontSize: 12,
|
||||
marginBottom: 2,
|
||||
color: theme.onSurface,
|
||||
},
|
||||
releaseDateCtn: {
|
||||
fontSize: 10,
|
||||
color: theme.onSurfaceVariant,
|
||||
},
|
||||
drawerElementContainer: {
|
||||
margin: 4,
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
borderRadius: 50,
|
||||
overflow: 'hidden',
|
||||
minHeight: 48,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: insets.bottom,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.outline,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ChapterDrawer;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user