Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2949638c | ||
|
|
f71aa4902b | ||
|
|
af7e411932 | ||
|
|
c54ac1a4c0 | ||
|
|
00d6a2e403 | ||
|
|
b67d0938b0 | ||
|
|
8447760cb5 | ||
|
|
24d2072586 | ||
|
|
a52ab28534 | ||
|
|
601713d8d7 | ||
|
|
7bfa5be53c | ||
|
|
ae3a66903a | ||
|
|
5438fd5d9b | ||
|
|
a74ffca03d | ||
|
|
c5a83d567e | ||
|
|
1b1f781dd9 | ||
|
|
988195e4fc | ||
|
|
4d4124458d | ||
|
|
0ae2843676 | ||
|
|
beaddc9eeb | ||
|
|
d843b3a14b | ||
|
|
b9da0eda8b | ||
|
|
ba75cda036 | ||
|
|
9d59af29c5 | ||
|
|
660d6e32f1 | ||
|
|
c07c663105 | ||
|
|
3673f6f7f9 | ||
|
|
e8120e12d0 | ||
|
|
f7459886b2 | ||
|
|
6fe5319c08 | ||
|
|
3a42208a36 | ||
|
|
e4eacce5bf | ||
|
|
d1fdea8d3e | ||
|
|
98e452e39e | ||
|
|
02e8fcc534 | ||
|
|
70852c2725 | ||
|
|
ea67d6e078 | ||
|
|
d2aa3b5347 | ||
|
|
937792ba91 | ||
|
|
fcfdaf59f8 | ||
|
|
0d4da998a1 | ||
|
|
0e7a32d9af | ||
|
|
4bb6b0baf6 | ||
|
|
0591b3224f | ||
|
|
eaf8dd21ff | ||
|
|
9bc6cccf26 | ||
|
|
eb3b53ee5b | ||
|
|
731af586af | ||
|
|
3acb70b8e1 | ||
|
|
836b1841e3 | ||
|
|
d37f4580a9 | ||
|
|
70fc964b4d | ||
|
|
c3c5fbfc79 | ||
|
|
04103f9280 | ||
|
|
69370efcf5 | ||
|
|
f0d3b0c8ae | ||
|
|
370c485048 | ||
|
|
7e4c910cd7 | ||
|
|
e8c1c80ce5 | ||
|
|
0d55f319c9 | ||
|
|
42ed51094e | ||
|
|
84c3fee5da | ||
|
|
b3af2c9481 | ||
|
|
33cab186fd | ||
|
|
e3f52412ee | ||
|
|
080810c707 | ||
|
|
964e827cbe | ||
|
|
cae02acfc4 | ||
|
|
31ce4eac92 | ||
|
|
bd5bd1e571 | ||
|
|
2b4a6dc57a | ||
|
|
68932731b9 | ||
|
|
aab7ba0d53 | ||
|
|
59bf4f2702 | ||
|
|
e0e4b739f9 | ||
|
|
21d6b3bbb8 | ||
|
|
dae5d67515 | ||
|
|
a6a814bbbb | ||
|
|
0b0555af9d | ||
|
|
6e0d77a8ab | ||
|
|
5c06a69c8a | ||
|
|
188a236a90 | ||
|
|
63ec1aa217 | ||
|
|
829316d8b9 | ||
|
|
337739fdad | ||
|
|
f144ac701d | ||
|
|
9c9b8b74cd | ||
|
|
154c7f3710 | ||
|
|
d3308bc987 | ||
|
|
04d9ee57d7 | ||
|
|
f3eecf18fb | ||
|
|
6591495d2d | ||
|
|
2497835628 | ||
|
|
8fad9b2abe | ||
|
|
fdd0ec9fd2 | ||
|
|
1219ff99ff | ||
|
|
c60b14998a | ||
|
|
380554bd19 | ||
|
|
2b969030da | ||
|
|
da917de1f4 | ||
|
|
541f9e89f3 | ||
|
|
f4a987c83f | ||
|
|
37474ed6ac | ||
|
|
cb69a168f3 | ||
|
|
94366553de | ||
|
|
383332bfbb | ||
|
|
4bb1a17d39 | ||
|
|
94d68fe38b | ||
|
|
cf6c850d2a | ||
|
|
c8a3a0fc20 | ||
|
|
433896b267 | ||
|
|
7e9c1f00ef | ||
|
|
2b5b0d695a | ||
|
|
2265b0943e | ||
|
|
78f12065ab | ||
|
|
f28c45a13a | ||
|
|
e030500917 | ||
|
|
9c042cd323 | ||
|
|
44eea7a273 | ||
|
|
834cfc5e8c | ||
|
|
876bb68873 | ||
|
|
40c1b7ef21 |
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Clippy Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.12.1] - 2024-09-28
|
||||
|
||||
### 新功能
|
||||
|
||||
* 屏蔽含有二维码的图片(位于高级设置)
|
||||
|
||||
### 改进
|
||||
|
||||
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
|
||||
* 画廊预览与画廊详情页面合并
|
||||
* 为快速搜索和下载标签列表添加了滚动条
|
||||
* 无网络条件下启动时自动转到下载页面
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
|
||||
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
|
||||
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
|
||||
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
|
||||
* 加载大于 100 MB 的位图时崩溃
|
||||
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
|
||||
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
|
||||
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
|
||||
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
|
||||
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
|
||||
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
|
||||
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
|
||||
* 选择无效的下载位置后闪退
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.12.0] - 2024-08-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
+23
-24
@@ -23,8 +23,25 @@ val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
android {
|
||||
compileSdk = 35
|
||||
buildToolsVersion = "35.0.0"
|
||||
ndkVersion = "27.0.12077973"
|
||||
androidResources.generateLocaleConfig = true
|
||||
ndkVersion = "27.1.12297006"
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
)
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
@@ -68,25 +85,9 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 180059
|
||||
versionName = "1.12.0"
|
||||
versionCode = 180060
|
||||
versionName = "1.12.1"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
),
|
||||
)
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("long", "COMMIT_TIME", commitTime)
|
||||
@@ -149,9 +150,6 @@ android {
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
create("benchmarkRelease") {
|
||||
initWith(buildTypes.getByName("release"))
|
||||
@@ -275,11 +273,12 @@ kotlin {
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xcontext-receivers",
|
||||
"-Xwhen-guards",
|
||||
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
|
||||
@@ -44,8 +44,9 @@ set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}"
|
||||
ExternalProject_Add(
|
||||
nettle
|
||||
PREFIX nettle
|
||||
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
|
||||
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
|
||||
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
|
||||
GIT_TAG nettle_3.10_release_20240616
|
||||
GIT_CONFIG core.autocrlf=input
|
||||
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
|
||||
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
|
||||
INSTALL_COMMAND ""
|
||||
@@ -89,7 +90,7 @@ set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
|
||||
GIT_TAG v3.7.4
|
||||
GIT_TAG v3.7.6
|
||||
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
|
||||
)
|
||||
|
||||
@@ -105,7 +106,10 @@ FetchContent_Declare(
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
|
||||
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
|
||||
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
|
||||
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
|
||||
endif ()
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
|
||||
@@ -1,241 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <android/bitmap.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/** A line will be considered as having content if 0.25% of it is filled. */
|
||||
const float filledRatioLimit = 0.0025;
|
||||
|
||||
/** When the threshold is closer to 1, less content will be cropped. **/
|
||||
#define THRESHOLD 0.75
|
||||
|
||||
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
|
||||
|
||||
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
|
||||
|
||||
static uint8_t inline grey(uint32_t pixel) {
|
||||
const uint8_t r = ((uint8_t *) &pixel)[0];
|
||||
const uint8_t g = ((uint8_t *) &pixel)[1];
|
||||
const uint8_t b = ((uint8_t *) &pixel)[2];
|
||||
return (r * 77 + g * 151 + b * 28) >> 8;
|
||||
}
|
||||
|
||||
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) < thresholdForBlack;
|
||||
}
|
||||
|
||||
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) > thresholdForWhite;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the left. */
|
||||
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, 0, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, 0, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = 1; x < width; x++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the right. */
|
||||
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
uint32_t lastX = width - 1;
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, lastX, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, lastX, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return width;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = width - 2; x > 0; x--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the top. */
|
||||
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, 0)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, 0)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = 1; y < height; y++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the bottom. */
|
||||
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
uint32_t lastY = height - 1;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, lastY)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, lastY)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return height;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = height - 2; y > 0; y--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return height;
|
||||
}
|
||||
|
||||
JNIEXPORT jintArray JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
|
||||
void *pixels = NULL;
|
||||
AndroidBitmapInfo info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
AndroidBitmap_getInfo(env, bitmap, &info);
|
||||
AndroidBitmap_lockPixels(env, bitmap, &pixels);
|
||||
uint32_t width = info.width;
|
||||
uint32_t height = info.height;
|
||||
uint32_t top = findBorderTop(pixels, width, height);
|
||||
uint32_t bottom = findBorderBottom(pixels, width, height);
|
||||
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
|
||||
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
|
||||
int array[4] = {left, top, right, bottom};
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
jintArray ret = (*env)->NewIntArray(env, 4);
|
||||
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
|
||||
return ret;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
|
||||
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
|
||||
if test -e $CI_NDK_LOCATION ;then
|
||||
TOOLCHAIN=$CI_NDK_LOCATION
|
||||
else
|
||||
TOOLCHAIN=$ARCH_NDK_LOCATION
|
||||
fi
|
||||
TARGET=$2
|
||||
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
AR=$TOOLCHAIN/llvm-ar \
|
||||
CC="$TOOLCHAIN/clang -target $TARGET" \
|
||||
RANLIB=$TOOLCHAIN/llvm-ranlib
|
||||
@@ -69,6 +69,11 @@ interface SystemUiController {
|
||||
isStatusBarVisible = value
|
||||
isNavigationBarVisible = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Property which holds whether the status bar icons + content are 'dark' or not.
|
||||
*/
|
||||
var statusBarDarkContentEnabled: Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,4 +154,10 @@ internal class AndroidSystemUiController(
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var statusBarDarkContentEnabled: Boolean
|
||||
get() = windowInsetsController?.isAppearanceLightStatusBars == true
|
||||
set(value) {
|
||||
windowInsetsController?.isAppearanceLightStatusBars = value
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
@@ -30,7 +29,7 @@ import coil3.SingletonImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ktor2.KtorNetworkFetcherFactory
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
@@ -40,9 +39,12 @@ import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.coil.CropBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DetectBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
|
||||
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
|
||||
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.coil.QrCodeInterceptor
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
@@ -55,6 +57,7 @@ import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.ui.tools.dataStateFlow
|
||||
import com.hippo.ehviewer.ui.tools.initSETConnection
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
@@ -67,6 +70,7 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
@@ -83,6 +87,7 @@ class EhApplication :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
initSETConnection()
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
val mode = Settings.theme
|
||||
@@ -136,9 +141,6 @@ class EhApplication :
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat(LogPriority.VERBOSE) { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +169,7 @@ class EhApplication :
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context) = context.imageLoader {
|
||||
interceptorCoroutineContext(Dispatchers.Default)
|
||||
components {
|
||||
serviceLoaderEnabled(false)
|
||||
add(KtorNetworkFetcherFactory { ktorClient })
|
||||
@@ -176,6 +179,9 @@ class EhApplication :
|
||||
add(HardwareBitmapInterceptor)
|
||||
}
|
||||
add(CropBorderInterceptor)
|
||||
add(DetectBorderInterceptor)
|
||||
add(QrCodeInterceptor)
|
||||
add(MapExtraInfoInterceptor)
|
||||
if (isAtLeastP) {
|
||||
add(AnimatedImageDecoder.Factory(false))
|
||||
} else {
|
||||
|
||||
@@ -150,7 +150,7 @@ object EhDB {
|
||||
if (artists.isNotEmpty()) {
|
||||
val dao = db.downloadArtistDao()
|
||||
dao.deleteByGid(gid)
|
||||
dao.insert(artists)
|
||||
dao.insertOrIgnore(artists)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.CHROME_MOBILE_USER_AGENT
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
@@ -123,7 +124,7 @@ object Settings : DataStorePreferences(null) {
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val displayName = stringOrNullPref("display_name", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
@@ -162,6 +163,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
var saveAsCbz by boolPref("save_as_cbz", false)
|
||||
var archiveMetadata by boolPref("archive_metadata", true)
|
||||
var desktopSite by boolPref("desktop_site", false)
|
||||
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
@@ -170,7 +172,6 @@ object Settings : DataStorePreferences(null) {
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var lastDawnDays by intPref("last_dawn_days", 0)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
|
||||
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
|
||||
|
||||
@@ -208,6 +209,10 @@ object Settings : DataStorePreferences(null) {
|
||||
val zoomStart = intPref("pref_zoom_start_key", 1)
|
||||
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
|
||||
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
|
||||
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
|
||||
|
||||
val userAgent
|
||||
get() = if (desktopSite) CHROME_USER_AGENT else CHROME_MOBILE_USER_AGENT
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
@@ -215,6 +220,9 @@ object Settings : DataStorePreferences(null) {
|
||||
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
|
||||
}
|
||||
}
|
||||
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
|
||||
recentDownloadLabel.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
interface Delegate<R> {
|
||||
|
||||
@@ -64,6 +64,7 @@ import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
@@ -333,7 +334,7 @@ object EhEngine {
|
||||
|
||||
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
|
||||
formBody {
|
||||
append("reset_imagelimit", "Reset Limit")
|
||||
append("reset_imagelimit", "Reset Quota")
|
||||
}
|
||||
}.fetchUsingAsText(HomeParser::parseResetLimits)
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
|
||||
const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
|
||||
|
||||
@@ -141,3 +141,6 @@ fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
|
||||
}
|
||||
|
||||
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
|
||||
|
||||
val GalleryInfo.hasAds
|
||||
get() = simpleTags?.any { "extraneous ads" in it } ?: false
|
||||
@@ -61,7 +61,7 @@ object GalleryDetailParser {
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
private val PATTERN_TORRENT =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
|
||||
private val PATTERN_ARCHIVE =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
|
||||
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
|
||||
|
||||
+4
-8
@@ -19,22 +19,18 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object GalleryMultiPageViewerPTokenParser {
|
||||
private const val IMAGE_LIST_STRING = "var imagelist = "
|
||||
|
||||
fun parse(body: String): List<String> {
|
||||
fun parse(body: String): List<String> = runCatching {
|
||||
val index = body.indexOf(IMAGE_LIST_STRING)
|
||||
val imagelist = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index))
|
||||
return runCatching {
|
||||
imagelist.parseAs<List<Item>>().map(Item::token)
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw ParseException("Parse pToken from MPV error", it)
|
||||
}
|
||||
imagelist.parseAs<List<Item>>().map(Item::token)
|
||||
}.getOrElse {
|
||||
throw ParseException("Parse pToken from MPV error", it)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,55 +1,58 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Extras
|
||||
import coil3.Image
|
||||
import coil3.getExtra
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toIntRect
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import coil3.asImage
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor.Chain
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.image.detectBorder
|
||||
import com.hippo.ehviewer.image.copyBitmapToAHB
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val FORMAT = HardwareBuffer.RGBA_8888
|
||||
|
||||
private val maybeCropBorderKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
|
||||
extras[maybeCropBorderKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.maybeCropBorder: Boolean
|
||||
get() = getExtra(maybeCropBorderKey)
|
||||
|
||||
class BitmapImageWithRect(
|
||||
val rect: IntRect,
|
||||
val image: BitmapImage,
|
||||
) : Image by image
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
|
||||
|
||||
object CropBorderInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.maybeCropBorder && result is SuccessResult) {
|
||||
if (result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
val bitmap = image.bitmap
|
||||
val ratio = if (image.height > image.width) {
|
||||
image.height / image.width
|
||||
} else {
|
||||
image.width / image.height
|
||||
}
|
||||
if (ratio < RATIO_THRESHOLD) {
|
||||
val array = detectBorder(bitmap)
|
||||
val minWidth = image.width * CROP_THRESHOLD
|
||||
val minHeight = image.height * CROP_THRESHOLD
|
||||
val rect = IntRect(array[0], array[1], array[2], array[3])
|
||||
if (rect.width > minWidth && rect.height > minHeight) {
|
||||
val new = BitmapImageWithRect(rect, image)
|
||||
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
|
||||
}
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
// Copy with cropped region
|
||||
val srcSize = IntSize(image.width, image.height)
|
||||
if (image.rect.size != srcSize) {
|
||||
val (x, y) = image.rect.topLeft
|
||||
val (w, h) = image.rect.size
|
||||
val src = image.image.bitmap
|
||||
|
||||
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
|
||||
// This is not ideal but I haven't figured out how to probe the threshold.
|
||||
// All we know is that it's less than the maximum texture size.
|
||||
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
|
||||
val bitmap = when {
|
||||
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
|
||||
resourceScope {
|
||||
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
|
||||
copyBitmapToAHB(src, buffer, x, y)
|
||||
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
|
||||
}
|
||||
}.onFailure { logcat(it) }.getOrNull()
|
||||
else -> null
|
||||
} ?: Bitmap.createBitmap(src, x, y, w, h)
|
||||
|
||||
src.recycle()
|
||||
return result.copy(image = image.copy(image = bitmap.asImage(), rect = image.rect.size.toIntRect()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.Extras
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor.Chain
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.image.detectBorder
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
|
||||
private val maybeCropBorderKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
|
||||
extras[maybeCropBorderKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.maybeCropBorder: Boolean
|
||||
get() = getExtra(maybeCropBorderKey)
|
||||
|
||||
object DetectBorderInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.maybeCropBorder && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImageWithExtraInfo && !image.hasQrCode) {
|
||||
val bitmap = image.image.bitmap
|
||||
val ratio = if (image.height > image.width) {
|
||||
image.height / image.width
|
||||
} else {
|
||||
image.width / image.height
|
||||
}
|
||||
if (ratio < RATIO_THRESHOLD) {
|
||||
val array = detectBorder(bitmap)
|
||||
val minWidth = image.width * CROP_THRESHOLD
|
||||
val minHeight = image.height * CROP_THRESHOLD
|
||||
val rect = IntRect(array[0], array[1], array[2], array[3])
|
||||
if (rect.width > minWidth && rect.height > minHeight) {
|
||||
val new = image.copy(rect = rect)
|
||||
return result.copy(image = new)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.isFile
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
|
||||
object DownloadThumbInterceptor : Interceptor {
|
||||
private const val THUMB_FILE = "thumb.jpg"
|
||||
@@ -18,22 +18,20 @@ object DownloadThumbInterceptor : Interceptor {
|
||||
if (magicOrUrl != null) {
|
||||
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
|
||||
if (location != null) {
|
||||
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
|
||||
if (withIOContext { thumb.isFile }) {
|
||||
val thumb = downloadLocation / location / THUMB_FILE
|
||||
if (thumb.isFile) {
|
||||
val new = chain.request.newBuilder().data(thumb.toUri()).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) return result
|
||||
}
|
||||
val new = chain.request.newBuilder().data(url).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) {
|
||||
withIOContext {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
if (result is SuccessResult && thumb.parent?.isDirectory == true) {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -3,8 +3,6 @@ package com.hippo.ehviewer.coil
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Extras
|
||||
import coil3.asImage
|
||||
import coil3.getExtra
|
||||
@@ -30,25 +28,18 @@ object HardwareBitmapInterceptor : Interceptor {
|
||||
val result = chain.proceed()
|
||||
val request = result.request
|
||||
if (!request.allowHardware && result is SuccessResult) {
|
||||
var rect: IntRect? = null
|
||||
val image = result.image
|
||||
val bitmap = when (image) {
|
||||
is BitmapImageWithRect -> image.image.also { rect = image.rect }
|
||||
is BitmapImage -> image
|
||||
else -> return result
|
||||
}.bitmap
|
||||
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
|
||||
// This is not ideal but I haven't figured out how to probe the threshold.
|
||||
// All we know is that it's less than the maximum texture size.
|
||||
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
|
||||
bitmap.recycle()
|
||||
val newImage = hwBitmap.asImage()
|
||||
return result.copy(
|
||||
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
|
||||
request = request,
|
||||
dataSource = result.dataSource,
|
||||
)
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
val bitmap = image.image.bitmap
|
||||
val isHardware = bitmap.config == Bitmap.Config.HARDWARE
|
||||
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
|
||||
// This is not ideal but I haven't figured out how to probe the threshold.
|
||||
// All we know is that it's less than the maximum texture size.
|
||||
if (!isHardware && maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
|
||||
bitmap.recycle()
|
||||
return result.copy(image = image.copy(image = hwBitmap.asImage()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Image
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
|
||||
data class BitmapImageWithExtraInfo(
|
||||
val image: BitmapImage,
|
||||
val rect: IntRect = IntRect(0, 0, image.width, image.height),
|
||||
val hasQrCode: Boolean = false,
|
||||
) : Image by image
|
||||
|
||||
object MapExtraInfoInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
val needMap = with(chain.request) { maybeCropBorder || detectQrCode || !allowHardware }
|
||||
if (needMap && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
return result.copy(image = BitmapImageWithExtraInfo(image))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object MergeInterceptor : Interceptor {
|
||||
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
|
||||
@@ -25,7 +24,9 @@ object MergeInterceptor : Interceptor {
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
// Wake up a pending continuation to continue executing task
|
||||
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply { resume(Unit) { triggerSuccessor(key) } }
|
||||
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply {
|
||||
resume(Unit) { _, _, _ -> triggerSuccessor(key) }
|
||||
}
|
||||
// If no successor, delete this entry from hashmap
|
||||
successor ?: pendingContinuationMap.remove(key)
|
||||
}
|
||||
@@ -42,14 +43,14 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
|
||||
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return chain.proceed()
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
val existPendingContinuations = pendingContinuationMap[key]
|
||||
if (existPendingContinuations == null) {
|
||||
pendingContinuationMap[key] = EMPTY_LIST
|
||||
continuation.resume(Unit) {
|
||||
continuation.resume(Unit) { _, _, _ ->
|
||||
triggerSuccessor(key)
|
||||
}
|
||||
} else {
|
||||
@@ -63,7 +64,7 @@ object MergeInterceptor : Interceptor {
|
||||
}
|
||||
|
||||
try {
|
||||
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
|
||||
return chain.proceed().apply {
|
||||
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.image.hasQrCode
|
||||
|
||||
private val detectQrCodeKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.detectQrCode(enable: Boolean) = apply {
|
||||
extras[detectQrCodeKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.detectQrCode: Boolean
|
||||
get() = getExtra(detectQrCodeKey)
|
||||
|
||||
object QrCodeInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.detectQrCode && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
val hasQrCode = hasQrCode(image.image.bitmap)
|
||||
val new = image.copy(hasQrCode = hasQrCode)
|
||||
return result.copy(image = new)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface DownloadArtistDao {
|
||||
@Insert
|
||||
suspend fun insert(downloadArtists: List<DownloadArtist>)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertOrIgnore(downloadArtists: List<DownloadArtist>)
|
||||
|
||||
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
|
||||
suspend fun deleteByGid(gid: Long)
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
|
||||
@Dao
|
||||
interface HistoryDao {
|
||||
@Query("SELECT * FROM HISTORY ORDER BY TIME")
|
||||
@Query("SELECT HISTORY.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME")
|
||||
suspend fun list(): List<HistoryInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
|
||||
|
||||
@@ -14,7 +14,7 @@ interface LocalFavoritesDao {
|
||||
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
|
||||
fun count(): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
|
||||
@Query("SELECT LOCAL_FAVORITES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME")
|
||||
suspend fun list(): List<LocalFavoriteInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
|
||||
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
|
||||
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun random(): BaseGalleryInfo
|
||||
suspend fun random(): BaseGalleryInfo?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
|
||||
suspend fun contains(gid: Long): Boolean
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.Settings.archivePasswds
|
||||
import com.hippo.ehviewer.image.ByteBufferSource
|
||||
import com.hippo.ehviewer.image.Image
|
||||
@@ -52,6 +53,7 @@ class ArchivePageLoader(
|
||||
private val file: Path,
|
||||
gid: Long = 0,
|
||||
startPage: Int = 0,
|
||||
private val hasAds: Boolean = false,
|
||||
passwdProvider: PasswdProvider? = null,
|
||||
) : PageLoader2(gid, startPage) {
|
||||
private lateinit var pfd: ParcelFileDescriptor
|
||||
@@ -104,6 +106,8 @@ class ArchivePageLoader(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mayBeAd(index: Int) = index > size - 10
|
||||
|
||||
private suspend fun doRealWork(index: Int) {
|
||||
val buffer = extractToByteBuffer(index) ?: return notifyPageFailed(index, null)
|
||||
check(buffer.isDirect)
|
||||
@@ -120,7 +124,7 @@ class ArchivePageLoader(
|
||||
src.close()
|
||||
throw it
|
||||
}
|
||||
val image = Image.decode(src) ?: return notifyPageFailed(index, null)
|
||||
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index)) ?: return notifyPageFailed(index, null)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
@@ -142,10 +146,6 @@ class ArchivePageLoader(
|
||||
override val isReady: Boolean
|
||||
get() = size != 0
|
||||
|
||||
override fun onCancelRequest(index: Int) {
|
||||
mJobMap[index]?.cancel()
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
FileUtils.getNameFromFilename(file.name)!!
|
||||
}
|
||||
|
||||
@@ -66,10 +66,6 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
override val isReady: Boolean
|
||||
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
|
||||
|
||||
override fun onCancelRequest(index: Int) {
|
||||
mSpiderQueen.cancelRequest(index)
|
||||
}
|
||||
|
||||
override fun onGetPages(pages: Int) {}
|
||||
|
||||
override fun onGet509(index: Int) {}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class Page(
|
||||
val index: Int,
|
||||
val statusFlow: MutableStateFlow<PageStatus> = MutableStateFlow(PageStatus.Queued),
|
||||
)
|
||||
|
||||
fun Page.unblock() = statusFlow.update { status ->
|
||||
when (status) {
|
||||
is PageStatus.Blocked -> PageStatus.Ready(status.ad)
|
||||
else -> error("Call unblock on page not blocked!!!")
|
||||
}
|
||||
}
|
||||
|
||||
fun Page.reset() = statusFlow.update { PageStatus.Queued }
|
||||
|
||||
val Page.status
|
||||
get() = statusFlow.value
|
||||
|
||||
val Page.statusObserved
|
||||
@Composable
|
||||
get() = statusFlow.collectAsState().value
|
||||
|
||||
val PageStatus.progressObserved
|
||||
@Composable
|
||||
get() = when (this) {
|
||||
is PageStatus.Loading -> progress.collectAsState().value
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
sealed interface PageStatus {
|
||||
data object Queued : PageStatus
|
||||
data class Blocked(val ad: Image) : PageStatus
|
||||
data class Ready(val image: Image) : PageStatus
|
||||
data class Error(val message: String?) : PageStatus
|
||||
data class Loading(val progress: MutableStateFlow<Float>) : PageStatus
|
||||
}
|
||||
@@ -18,7 +18,8 @@
|
||||
package com.hippo.ehviewer.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import android.hardware.HardwareBuffer
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
@@ -33,7 +34,8 @@ import coil3.request.allowHardware
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Precision
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.coil.BitmapImageWithRect
|
||||
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
|
||||
import com.hippo.ehviewer.coil.detectQrCode
|
||||
import com.hippo.ehviewer.coil.hardwareThreshold
|
||||
import com.hippo.ehviewer.coil.maybeCropBorder
|
||||
import com.hippo.ehviewer.jni.isGif
|
||||
@@ -51,14 +53,15 @@ import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class Image private constructor(image: CoilImage, private val src: ImageSource) {
|
||||
val size = image.size
|
||||
val rect = when (image) {
|
||||
is BitmapImageWithRect -> image.rect
|
||||
else -> image.run { IntRect(0, 0, width, height) }
|
||||
val intrinsicSize = with(image) { IntSize(width, height) }
|
||||
val allocationSize = image.size
|
||||
val hasQrCode = when (image) {
|
||||
is BitmapImageWithExtraInfo -> image.hasQrCode
|
||||
else -> false
|
||||
}
|
||||
|
||||
var innerImage: CoilImage? = when (image) {
|
||||
is BitmapImageWithRect -> image.image
|
||||
is BitmapImageWithExtraInfo -> image.image
|
||||
else -> image
|
||||
}
|
||||
|
||||
@@ -76,7 +79,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
companion object {
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
|
||||
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
|
||||
val req = appCtx.imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.toUri()) }
|
||||
@@ -85,6 +88,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
detectQrCode(checkExtraneousAds)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
}
|
||||
return when (val result = appCtx.imageLoader.execute(req)) {
|
||||
@@ -93,7 +97,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decode(src: ImageSource): Image? {
|
||||
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image? {
|
||||
return runCatching {
|
||||
val image = when (src) {
|
||||
is PathSource -> {
|
||||
@@ -109,18 +113,18 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
src.close()
|
||||
}
|
||||
}
|
||||
return decode(source)
|
||||
return decode(source, checkExtraneousAds)
|
||||
}
|
||||
}
|
||||
}
|
||||
src.right().decodeCoil()
|
||||
src.right().decodeCoil(checkExtraneousAds)
|
||||
}
|
||||
|
||||
is ByteBufferSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
rewriteGifSource(src.source)
|
||||
}
|
||||
src.left().decodeCoil()
|
||||
src.left().decodeCoil(checkExtraneousAds)
|
||||
}
|
||||
}
|
||||
Image(image, src).apply {
|
||||
@@ -146,3 +150,5 @@ interface ByteBufferSource : ImageSource {
|
||||
}
|
||||
|
||||
external fun detectBorder(bitmap: Bitmap): IntArray
|
||||
external fun hasQrCode(bitmap: Bitmap): Boolean
|
||||
external fun copyBitmapToAHB(src: Bitmap, dst: HardwareBuffer, x: Int, y: Int)
|
||||
@@ -15,6 +15,7 @@ import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.writeFully
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@@ -46,6 +46,8 @@ import com.hippo.files.exists
|
||||
import com.hippo.files.find
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.list
|
||||
import com.hippo.files.mkdirs
|
||||
import com.hippo.files.moveTo
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import com.hippo.files.openOutputStream
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
@@ -54,7 +56,7 @@ import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import io.ktor.utils.io.copyTo
|
||||
import kotlin.io.path.readText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okio.Path
|
||||
@@ -90,10 +92,10 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
mode = value
|
||||
if (mode == SpiderQueen.MODE_DOWNLOAD) {
|
||||
if (downloadDir == null) {
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
|
||||
}
|
||||
if (saveAsCbz && tempDownloadDir == null) {
|
||||
tempDownloadDir = info.tempDownloadDir!!
|
||||
tempDownloadDir = info.tempDownloadDir!!.apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,9 +105,11 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return sCache.read(key) { true } ?: false
|
||||
}
|
||||
|
||||
private fun findImageFile(index: Int) = synchronized(fileCache) {
|
||||
private fun findImageFile(index: Int, temp: Boolean = false) = synchronized(fileCache) {
|
||||
val head = perFilename(index)
|
||||
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
|
||||
fileCache.entries.firstOrNull { (name) ->
|
||||
name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX)
|
||||
}?.value
|
||||
}
|
||||
|
||||
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
|
||||
@@ -144,14 +148,17 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return sCache.remove(key)
|
||||
}
|
||||
|
||||
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
|
||||
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.run {
|
||||
delete()
|
||||
synchronized(fileCache) {
|
||||
fileCache.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
|
||||
fun removeIntermediateFiles(index: Int) {
|
||||
removeFromCache(index)
|
||||
removeTempFile(index)
|
||||
}
|
||||
|
||||
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
|
||||
val name = perFilename(index, extension)
|
||||
@@ -185,7 +192,15 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
crossinline fops: suspend (Path) -> Unit,
|
||||
): Boolean {
|
||||
imageDir?.run {
|
||||
fops(findDownloadFileForIndex(index, ext))
|
||||
val tempFile = findDownloadFileForIndex(index, "$ext$TEMP_SUFFIX")
|
||||
fops(tempFile)
|
||||
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
|
||||
file.delete()
|
||||
tempFile.moveTo(file)
|
||||
synchronized(fileCache) {
|
||||
fileCache.remove(tempFile.name)
|
||||
fileCache[file.name] = file
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -328,7 +343,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
|
||||
suspend fun initDownloadDir() {
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
|
||||
}
|
||||
|
||||
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
|
||||
@@ -353,6 +368,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
private const val TEMP_SUFFIX = ".tmp"
|
||||
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
|
||||
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.hippo.ehviewer.client.EhUrl.getGalleryDetailUrl
|
||||
import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
|
||||
import com.hippo.ehviewer.client.EhUrl.referer
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.hasAds
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.exception.QuotaExceededException
|
||||
import com.hippo.ehviewer.client.fetchUsingAsText
|
||||
@@ -284,10 +285,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest(index: Int) {
|
||||
mWorkerScope.cancelDecode(index)
|
||||
}
|
||||
|
||||
fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
|
||||
mWorkerScope.updateRAList(pages, pair)
|
||||
}
|
||||
@@ -507,15 +504,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
private val showKeyLock = Mutex()
|
||||
private val mDownloadDelay = Settings.downloadDelay.milliseconds
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private var lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
var isDownloadMode = false
|
||||
private set
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
decoder.cancel(index)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun enterDownloadMode() {
|
||||
if (isDownloadMode) return
|
||||
@@ -548,13 +540,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
runCatching {
|
||||
if (!force && index in mSpiderDen) {
|
||||
return@runCatching updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg, skipHath)
|
||||
}
|
||||
@@ -591,7 +576,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
?: getPTokenFromInternet(index)
|
||||
?: getPTokenFromMultiPageViewer(index)
|
||||
}
|
||||
updatePageState(index, STATE_DOWNLOADING)
|
||||
val previousPToken: String?
|
||||
val pToken: String
|
||||
pTokenLock.withLock {
|
||||
@@ -599,13 +583,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
|
||||
mSpiderInfo.pTokenMap.remove(index)
|
||||
}
|
||||
} else if (index in mSpiderDen) {
|
||||
return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
pToken = getPToken(index)
|
||||
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
|
||||
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
|
||||
}
|
||||
previousPToken = getPToken(index - 1)
|
||||
|
||||
// The lock for delay should be acquired before anything else to maintain FIFO order
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
updatePageState(index, STATE_DOWNLOADING)
|
||||
|
||||
var skipHathKey: String? = null
|
||||
var originImageUrl: String? = null
|
||||
@@ -694,7 +685,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
updatePageState(index, STATE_FINISHED)
|
||||
return
|
||||
}.onFailure {
|
||||
mSpiderDen.remove(index)
|
||||
mSpiderDen.removeIntermediateFiles(index)
|
||||
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
|
||||
error = when (it) {
|
||||
is TimeoutCancellationException -> ERROR_TIMEOUT
|
||||
@@ -749,9 +740,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
|
||||
private fun mayBeAd(index: Int) = index > size - 10
|
||||
|
||||
private val hasAds = galleryInfo.hasAds
|
||||
|
||||
private suspend fun doInJob(index: Int) {
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = Image.decode(src)
|
||||
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index))
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf(
|
||||
R.string.favorite_name to favoriteName,
|
||||
)
|
||||
|
||||
context(Context, DestinationsNavigator)
|
||||
@Composable
|
||||
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
|
||||
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
text = stringResource(id = R.string.gallery_info),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
@@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
|
||||
if (index == INDEX_PARENT) {
|
||||
if (content != null) {
|
||||
navigator.navWithUrl(content)
|
||||
navWithUrl(content)
|
||||
}
|
||||
} else {
|
||||
context.addTextToClipboard(content, true)
|
||||
addTextToClipboard(content, true)
|
||||
if (index == INDEX_URL) {
|
||||
// Save it to avoid detect the gallery
|
||||
Settings.clipboardTextHashCode = data[index].hashCode()
|
||||
|
||||
@@ -26,7 +26,6 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -138,6 +137,7 @@ import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.NoopSharedTransitionScope
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
@@ -234,7 +234,7 @@ class MainActivity : EhActivity() {
|
||||
suspend fun DialogState.checkDownloadLocation() {
|
||||
val valid = withIOContext { downloadLocation.isDirectory }
|
||||
if (!valid) {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.open_settings,
|
||||
title = R.string.waring,
|
||||
showCancelButton = false,
|
||||
@@ -248,18 +248,24 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val hasNetwork = remember { connectivityManager.activeNetwork != null }
|
||||
if (!AppConfig.isBenchmark) {
|
||||
val noNetwork = stringResource(R.string.no_network)
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { dialogState.checkDownloadLocation() }
|
||||
runCatching { dialogState.checkAppLinkVerify() }
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
if (hasNetwork) {
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
|
||||
}
|
||||
}.onFailure {
|
||||
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
|
||||
} else {
|
||||
snackbarState.showSnackbar(noNetwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,16 +469,22 @@ class MainActivity : EhActivity() {
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && drawerEnabled,
|
||||
) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
// https://issuetracker.google.com/336140982
|
||||
// SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
|
||||
val start = when {
|
||||
Settings.needSignIn -> SignInScreenDestination
|
||||
hasNetwork -> StartDestination
|
||||
else -> DownloadsScreenDestination
|
||||
}
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
start = start,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,7 +506,7 @@ class MainActivity : EhActivity() {
|
||||
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
|
||||
if (hasUnverified) {
|
||||
var checked by mutableStateOf(false)
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.open_settings,
|
||||
title = R.string.app_link_not_verified_title,
|
||||
onCancelButtonClick = {
|
||||
@@ -525,7 +537,7 @@ class MainActivity : EhActivity() {
|
||||
Uri.parse("package:$packageName"),
|
||||
)
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
} catch (_: Throwable) {
|
||||
val intent = Intent(
|
||||
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:$packageName"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -19,7 +20,7 @@ import soup.compose.material.motion.animation.materialSharedAxisXOut
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
|
||||
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
|
||||
ProvideVectorPainterCache {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
|
||||
@@ -116,7 +116,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
}.onFailure {
|
||||
withUIContext {
|
||||
focusManager.clearFocus()
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.get_it,
|
||||
title = R.string.sign_in_failed,
|
||||
showCancelButton = false,
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.listThumbGenerator
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
|
||||
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
@@ -108,10 +109,12 @@ fun DownloadCard(
|
||||
Row {
|
||||
val thumb = remember {
|
||||
movableContentOf<DownloadInfo> {
|
||||
EhAsyncCropThumb(
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectMode) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.SETNodeGenerator
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
|
||||
@@ -27,7 +28,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
|
||||
return remember(model) { context.imageRequest(model) }
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun EhAsyncThumb(
|
||||
@@ -39,7 +40,6 @@ fun EhAsyncThumb(
|
||||
model = requestOf(model),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${model.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = onSuccess?.let { callback ->
|
||||
@@ -48,7 +48,7 @@ fun EhAsyncThumb(
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
fun EhAsyncCropThumb(
|
||||
key: GalleryInfo,
|
||||
@@ -59,7 +59,6 @@ fun EhAsyncCropThumb(
|
||||
model = requestOf(key),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${key.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = {
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.detailThumbGenerator
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailHeaderInfoCard(
|
||||
@@ -109,13 +110,15 @@ fun GalleryDetailHeaderCard(
|
||||
) = ElevatedCard(modifier = modifier) {
|
||||
Row {
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.size(
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
|
||||
),
|
||||
)
|
||||
with(detailThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.size(
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(0.5F))
|
||||
Column(
|
||||
|
||||
@@ -53,6 +53,7 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.ElevatedCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.listThumbGenerator
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
|
||||
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
|
||||
@@ -108,10 +109,12 @@ fun GalleryInfoListItem(
|
||||
) {
|
||||
Row {
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
@@ -227,13 +230,15 @@ fun GalleryInfoGridItem(
|
||||
}
|
||||
mutableFloatStateOf(ratio)
|
||||
}
|
||||
EhAsyncThumb(
|
||||
model = info,
|
||||
modifier = Modifier.aspectRatio(ratio),
|
||||
onSuccess = {
|
||||
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
},
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncThumb(
|
||||
model = info,
|
||||
modifier = Modifier.aspectRatio(ratio),
|
||||
onSuccess = {
|
||||
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
Badge(
|
||||
modifier = Modifier.align(Alignment.TopEnd).widthIn(min = 32.dp).height(24.dp),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toAndroidRectF
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
|
||||
class BitmapPainter(private val bitmap: Bitmap, override val intrinsicSize: Size) : Painter() {
|
||||
private val srcRect = intrinsicSize.toRect().toAndroidRectF()
|
||||
private val dstRect = RectF()
|
||||
private val matrix = Matrix()
|
||||
|
||||
// Use the overload that takes a `Matrix` to bypass the 100 MB size limit
|
||||
override fun DrawScope.onDraw() = drawIntoCanvas { canvas ->
|
||||
dstRect.right = size.width.fastRoundToInt().toFloat()
|
||||
dstRect.bottom = size.height.fastRoundToInt().toFloat()
|
||||
matrix.setRectToRect(srcRect, dstRect, Matrix.ScaleToFit.FILL)
|
||||
canvas.nativeCanvas.drawBitmap(bitmap, matrix, paint)
|
||||
}
|
||||
}
|
||||
|
||||
private val paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
isFilterBitmap = true
|
||||
isDither = true
|
||||
}
|
||||
@@ -1,27 +1,36 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFold
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
@Composable
|
||||
fun GalleryPager(
|
||||
@@ -31,10 +40,9 @@ fun GalleryPager(
|
||||
pageLoader: PageLoader2,
|
||||
showNavigationOverlay: Boolean,
|
||||
onNavigationModeChange: () -> Unit,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val isPagerType = !ReadingModeType.isWebtoon(type)
|
||||
val pagerNavigation by Settings.readerPagerNav.collectAsState()
|
||||
@@ -53,11 +61,18 @@ fun GalleryPager(
|
||||
} else {
|
||||
onNavigationModeChange()
|
||||
}
|
||||
navigation.invertMode = TappingInvertMode.entries[invertMode]
|
||||
navigation.regions
|
||||
navigation.regions(TappingInvertMode.entries[invertMode])
|
||||
}
|
||||
val navigator by rememberUpdatedState(regions)
|
||||
if (isPagerType) {
|
||||
val channel = remember { Channel<Float>(Channel.CONFLATED) }
|
||||
LaunchedEffect(channel) {
|
||||
channel.receiveAsFlow().collectLatest { delta ->
|
||||
if (delta != 0f) {
|
||||
if (delta < 0) pagerState.moveToNext() else pagerState.moveToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
PagerViewer(
|
||||
pagerState = pagerState,
|
||||
isRtl = type == RIGHT_TO_LEFT,
|
||||
@@ -66,8 +81,15 @@ fun GalleryPager(
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
modifier = modifier.pointerInput(channel) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitScrollEvent()
|
||||
val delta = calculateMouseWheelScroll(event)
|
||||
channel.trySend(delta)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
WebtoonViewer(
|
||||
@@ -77,9 +99,21 @@ fun GalleryPager(
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
private suspend fun AwaitPointerEventScope.awaitScrollEvent(): PointerEvent {
|
||||
var event: PointerEvent
|
||||
do {
|
||||
event = awaitPointerEvent()
|
||||
} while (event.type != PointerEventType.Scroll)
|
||||
return event
|
||||
}
|
||||
|
||||
private fun Density.calculateMouseWheelScroll(event: PointerEvent): Float {
|
||||
// 64 dp value is taken from ViewConfiguration.java, replace with better solution
|
||||
return event.changes.fastFold(0f) { acc, c -> acc + c.scrollDelta.y } * -64.dp.toPx()
|
||||
}
|
||||
@@ -46,12 +46,17 @@ fun NavigationOverlay(
|
||||
val scaleX = maxWidth
|
||||
val scaleY = maxHeight
|
||||
regions.forEach { region ->
|
||||
val rect = region.rectF
|
||||
val rect = region.rect
|
||||
val color = colorResource(region.type.colorRes)
|
||||
val text = stringResource(region.type.nameRes)
|
||||
Box(
|
||||
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
|
||||
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
|
||||
modifier = Modifier.size(
|
||||
width = scaleX * rect.width,
|
||||
height = scaleY * rect.height,
|
||||
).offset(
|
||||
x = scaleX * rect.left,
|
||||
y = scaleY * rect.top,
|
||||
).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = strokeStyle)
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.hippo.ehviewer.BuildConfig.APPLICATION_ID
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
@@ -25,7 +26,6 @@ import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.hippo.files.toOkioPath
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.File
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -42,7 +42,7 @@ private fun Context.provideImage(index: Int): Uri? {
|
||||
}
|
||||
|
||||
context(SnackbarHostState, Context, PageLoader2)
|
||||
suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
|
||||
suspend fun shareImage(page: Page, info: GalleryInfo? = null) {
|
||||
val error = getString(R.string.error_cant_save_image)
|
||||
val share = getString(R.string.share_image)
|
||||
val noActivity = getString(R.string.error_cant_find_activity)
|
||||
@@ -67,7 +67,7 @@ suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
|
||||
}
|
||||
|
||||
context(SnackbarHostState, Context, PageLoader2)
|
||||
suspend fun copy(page: ReaderPage) {
|
||||
suspend fun copy(page: Page) {
|
||||
val error = getString(R.string.error_cant_save_image)
|
||||
val copied = getString(R.string.copied_to_clipboard)
|
||||
val uri = provideImage(page.index)
|
||||
@@ -83,7 +83,7 @@ suspend fun copy(page: ReaderPage) {
|
||||
}
|
||||
|
||||
context(SnackbarHostState, Context, PageLoader2)
|
||||
suspend fun save(page: ReaderPage) {
|
||||
suspend fun save(page: Page) {
|
||||
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
val cannotSave = getString(R.string.error_cant_save_image)
|
||||
if (granted) {
|
||||
@@ -137,7 +137,7 @@ suspend fun save(page: ReaderPage) {
|
||||
}
|
||||
|
||||
context(SnackbarHostState, Context, PageLoader2)
|
||||
suspend fun saveTo(page: ReaderPage) {
|
||||
suspend fun saveTo(page: Page) {
|
||||
val filename = getImageFilename(page.index)
|
||||
if (filename == null) {
|
||||
showSnackbar(getString(R.string.error_cant_save_image))
|
||||
|
||||
@@ -20,28 +20,34 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import coil3.BitmapImage
|
||||
import coil3.DrawableImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.google.accompanist.drawablepainter.DrawablePainter
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageStatus
|
||||
import com.hippo.ehviewer.gallery.progressObserved
|
||||
import com.hippo.ehviewer.gallery.statusObserved
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import com.hippo.ehviewer.ui.settings.AdsPlaceholderFile
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.CombinedCircularProgressIndicator
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
@Composable
|
||||
fun PagerItem(
|
||||
page: ReaderPage,
|
||||
page: Page,
|
||||
pageLoader: PageLoader,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -49,21 +55,24 @@ fun PagerItem(
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
pageLoader.request(page.index)
|
||||
page.statusFlow.drop(1).collect {
|
||||
if (page.statusFlow.value == PageStatus.Queued) {
|
||||
pageLoader.request(page.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
val defaultError = stringResource(id = R.string.decode_image_error)
|
||||
val state by page.status.collectAsState()
|
||||
when (state) {
|
||||
Page.State.QUEUE, Page.State.LOAD_PAGE, Page.State.DOWNLOAD_IMAGE -> {
|
||||
when (val state = page.statusObserved) {
|
||||
is PageStatus.Queued, is PageStatus.Loading -> {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val progress by page.progressFlow.collectAsState()
|
||||
CombinedCircularProgressIndicator(progress = progress / 100f)
|
||||
CombinedCircularProgressIndicator(progress = state.progressObserved)
|
||||
}
|
||||
}
|
||||
Page.State.READY -> {
|
||||
val image = page.image!!
|
||||
is PageStatus.Ready -> {
|
||||
val image = state.image
|
||||
val painter = remember(image) { image.toPainter() }
|
||||
val grayScale by Settings.grayScale.collectAsState()
|
||||
val invert by Settings.invertedColors.collectAsState()
|
||||
@@ -91,14 +100,36 @@ fun PagerItem(
|
||||
},
|
||||
)
|
||||
}
|
||||
Page.State.ERROR -> {
|
||||
is PageStatus.Blocked -> {
|
||||
SubcomposeAsyncImage(
|
||||
model = AdsPlaceholderFile,
|
||||
contentDescription = null,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentScale = if (contentScale == ContentScale.Inside) ContentScale.Fit else contentScale,
|
||||
) {
|
||||
val placeholderState by painter.state.collectAsState()
|
||||
if (placeholderState is AsyncImagePainter.State.Success) {
|
||||
SubcomposeAsyncImageContent()
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (placeholderState is AsyncImagePainter.State.Error) {
|
||||
Text(text = stringResource(id = R.string.blocked_image))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is PageStatus.Error -> {
|
||||
Box(modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = page.errorMsg ?: defaultError,
|
||||
text = state.message ?: defaultError,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -113,11 +144,7 @@ fun PagerItem(
|
||||
}
|
||||
|
||||
private fun Image.toPainter() = when (val image = innerImage) {
|
||||
is BitmapImage -> BitmapPainter(
|
||||
image = image.bitmap.asImageBitmap(),
|
||||
srcOffset = rect.topLeft,
|
||||
srcSize = rect.size,
|
||||
)
|
||||
is BitmapImage -> BitmapPainter(image.bitmap, intrinsicSize.toSize())
|
||||
is DrawableImage -> DrawablePainter(image.drawable)
|
||||
else -> unreachable()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
@@ -32,9 +31,10 @@ import androidx.compose.ui.unit.toSize
|
||||
import arrow.core.partially1
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import com.hippo.ehviewer.gallery.PageStatus
|
||||
import com.hippo.ehviewer.gallery.statusObserved
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
|
||||
@@ -56,9 +56,8 @@ fun PagerViewer(
|
||||
isVertical: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -76,7 +75,6 @@ fun PagerViewer(
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
@@ -100,7 +98,6 @@ fun PagerViewer(
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = isRtl xor isRtlLayout,
|
||||
key = { it },
|
||||
) { index ->
|
||||
@@ -125,7 +122,7 @@ fun PagerViewer(
|
||||
|
||||
@Composable
|
||||
private fun PageContainer(
|
||||
page: ReaderPage,
|
||||
page: Page,
|
||||
pageLoader: PageLoader2,
|
||||
isRtl: Boolean,
|
||||
scaleType: Int,
|
||||
@@ -134,16 +131,16 @@ private fun PageContainer(
|
||||
layoutSize: Size,
|
||||
navigator: () -> NavigationRegions,
|
||||
pagerState: PagerState,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val isRtl by rememberUpdatedState(isRtl)
|
||||
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
if (status == Page.State.READY && layoutSize != Size.Zero) {
|
||||
val size = page.image!!.rect.size.toSize()
|
||||
val status = page.statusObserved
|
||||
if (status is PageStatus.Ready && layoutSize != Size.Zero) {
|
||||
val size = status.image.intrinsicSize.toSize()
|
||||
val contentScale = ContentScale.fromPreferences(scaleType, size, layoutSize)
|
||||
zoomableState.contentScale = contentScale
|
||||
LaunchedEffect(size, contentScale, alignment) {
|
||||
@@ -188,11 +185,11 @@ private fun PageContainer(
|
||||
with(pagerState) {
|
||||
// Don't use `layoutSize` as it may capture outdated value
|
||||
val size = layoutInfo.viewportSize.toSize()
|
||||
val x = offset.x / size.width
|
||||
val y = offset.y / size.height
|
||||
val (w, h) = size
|
||||
val (x, y) = offset
|
||||
val distance = size.width
|
||||
val bounds = size.toRect()
|
||||
when (navigator().getAction(x, y)) {
|
||||
when (navigator().getAction(Offset(x / w, y / h))) {
|
||||
NavigationRegion.MENU -> onMenuRegionClick()
|
||||
NavigationRegion.NEXT -> {
|
||||
val canPan = if (isRtl) panRight(distance, bounds) else panLeft(distance, bounds)
|
||||
|
||||
@@ -10,10 +10,8 @@ import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -49,7 +47,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.ensure
|
||||
@@ -57,15 +54,22 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.hasAds
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.archiveFile
|
||||
import com.hippo.ehviewer.gallery.ArchivePageLoader
|
||||
import com.hippo.ehviewer.gallery.EhPageLoader
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import com.hippo.ehviewer.gallery.PageStatus
|
||||
import com.hippo.ehviewer.gallery.status
|
||||
import com.hippo.ehviewer.gallery.unblock
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -79,6 +83,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -93,6 +98,14 @@ sealed interface ReaderScreenArgs : Parcelable {
|
||||
data class Archive(val uri: Uri) : ReaderScreenArgs
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Background(
|
||||
color: Color,
|
||||
content: @Composable () -> Unit,
|
||||
) = Box(Modifier.fillMaxSize().background(color), contentAlignment = Alignment.Center) {
|
||||
EhTheme(useDarkTheme = color != Color.White, content = content)
|
||||
}
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
@@ -105,6 +118,18 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
|
||||
}
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
val readingFailed = stringResource(R.string.error_reading_failed)
|
||||
val uiController = rememberSystemUiController()
|
||||
DisposableEffect(uiController) {
|
||||
val lightStatusBar = uiController.statusBarDarkContentEnabled
|
||||
onDispose {
|
||||
uiController.statusBarDarkContentEnabled = lightStatusBar
|
||||
}
|
||||
}
|
||||
LaunchedEffect(uiController) {
|
||||
snapshotFlow { bgColor }.collect {
|
||||
uiController.statusBarDarkContentEnabled = it == Color.White
|
||||
}
|
||||
}
|
||||
Await(
|
||||
{
|
||||
Either.catch {
|
||||
@@ -112,7 +137,7 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
|
||||
}.fold({ it.displayString() }, { it })
|
||||
},
|
||||
placeholder = {
|
||||
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
|
||||
Background(bgColor) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
@@ -123,7 +148,7 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
|
||||
ReaderScreen(pageLoader, info, navigator)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
|
||||
Background(bgColor) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
@@ -145,8 +170,10 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
|
||||
.collect { setOrientation(it) }
|
||||
}
|
||||
LaunchedEffect(pageLoader) {
|
||||
Settings.cropBorder.changesFlow().collect {
|
||||
pageLoader.restart()
|
||||
with(Settings) {
|
||||
merge(cropBorder.changesFlow(), stripExtraneousAds.changesFlow()).collect {
|
||||
pageLoader.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
@@ -191,57 +218,62 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
|
||||
Settings.showNavigationOverlayNewUser.value = false
|
||||
mutableStateOf(showOnStart)
|
||||
}
|
||||
GalleryPager(
|
||||
type = readingMode,
|
||||
pagerState = pagerState,
|
||||
lazyListState = lazyListState,
|
||||
pageLoader = pageLoader,
|
||||
showNavigationOverlay = showNavigationOverlay,
|
||||
onNavigationModeChange = { showNavigationOverlay = true },
|
||||
onSelectPage = { page ->
|
||||
if (Settings.readerLongTapAction.value) {
|
||||
launch {
|
||||
dialog { cont ->
|
||||
fun dispose() = cont.resume(Unit)
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
sheetState = state,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
ReaderPageSheetMeta(
|
||||
retry = { pageLoader.retryPage(page.index) },
|
||||
retryOrigin = { pageLoader.retryPage(page.index, true) },
|
||||
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
|
||||
copy = { launchIO { with(pageLoader) { copy(page) } } },
|
||||
save = { launchIO { with(pageLoader) { save(page) } } },
|
||||
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
|
||||
dismiss = { launch { state.hide().also { dispose() } } },
|
||||
)
|
||||
}
|
||||
val onSelectPage = { page: Page ->
|
||||
if (Settings.readerLongTapAction.value) {
|
||||
launch {
|
||||
val blocked = page.status is PageStatus.Blocked
|
||||
dialog { cont ->
|
||||
fun dispose() = cont.resume(Unit)
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
sheetState = state,
|
||||
contentWindowInsets = { EmptyWindowInsets },
|
||||
) {
|
||||
ReaderPageSheetMeta(
|
||||
retry = { pageLoader.retryPage(page.index) },
|
||||
retryOrigin = { pageLoader.retryPage(page.index, true) },
|
||||
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
|
||||
copy = { launchIO { with(pageLoader) { copy(page) } } },
|
||||
save = { launchIO { with(pageLoader) { save(page) } } },
|
||||
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
|
||||
showAds = { page.unblock() }.takeIf { blocked },
|
||||
dismiss = { launch { state.hide().also { dispose() } } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor).pointerInput(syncState) {
|
||||
awaitEachGesture {
|
||||
waitForUpOrCancellation()
|
||||
syncState.reset()
|
||||
showNavigationOverlay = false
|
||||
}
|
||||
},
|
||||
contentPadding = if (fullscreen) {
|
||||
}
|
||||
}
|
||||
EhTheme(useDarkTheme = bgColor != Color.White) {
|
||||
val insets = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
PaddingValues(0.dp)
|
||||
EmptyWindowInsets
|
||||
} else {
|
||||
WindowInsets.displayCutout.asPaddingValues()
|
||||
WindowInsets.displayCutout
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars.asPaddingValues()
|
||||
},
|
||||
)
|
||||
WindowInsets.systemBars
|
||||
}
|
||||
GalleryPager(
|
||||
type = readingMode,
|
||||
pagerState = pagerState,
|
||||
lazyListState = lazyListState,
|
||||
pageLoader = pageLoader,
|
||||
showNavigationOverlay = showNavigationOverlay,
|
||||
onNavigationModeChange = { showNavigationOverlay = true },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor).pointerInput(syncState) {
|
||||
awaitEachGesture {
|
||||
waitForUpOrCancellation()
|
||||
syncState.reset()
|
||||
showNavigationOverlay = false
|
||||
}
|
||||
}.fillMaxSize().windowInsetsPadding(insets),
|
||||
)
|
||||
}
|
||||
val brightness by Settings.customBrightness.collectAsState()
|
||||
val brightnessValue by Settings.customBrightnessValue.collectAsState()
|
||||
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
|
||||
@@ -301,7 +333,7 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
contentWindowInsets = { EmptyWindowInsets },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
@@ -322,7 +354,7 @@ private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
|
||||
val page = args.page
|
||||
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
|
||||
if (archive != null) {
|
||||
ArchivePageLoader(archive, info.gid, page)
|
||||
ArchivePageLoader(archive, info.gid, page, info.hasAds)
|
||||
} else {
|
||||
EhPageLoader(info, page)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
|
||||
@@ -34,9 +34,8 @@ fun WebtoonViewer(
|
||||
withGaps: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -54,13 +53,12 @@ fun WebtoonViewer(
|
||||
LazyColumn(
|
||||
modifier = modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = {
|
||||
onClick = { offset ->
|
||||
scope.launch {
|
||||
with(lazyListState) {
|
||||
val size = layoutInfo.viewportSize
|
||||
val x = it.x / size.width
|
||||
val y = it.y / size.height
|
||||
when (navigator().getAction(x, y)) {
|
||||
val (w, h) = layoutInfo.viewportSize
|
||||
val (x, y) = offset
|
||||
when (navigator().getAction(Offset(x / w, y / h))) {
|
||||
NavigationRegion.MENU -> onMenuRegionClick()
|
||||
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
|
||||
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
|
||||
@@ -72,17 +70,14 @@ fun WebtoonViewer(
|
||||
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
|
||||
info.offset <= ofs.y && info.offset + info.size > ofs.y
|
||||
}
|
||||
if (info == null && withGaps) {
|
||||
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
|
||||
return@zoomable
|
||||
if (info != null) {
|
||||
onSelectPage(items[info.index])
|
||||
}
|
||||
info ?: error("Internal error finding long click item!!! offset:$ofs")
|
||||
onSelectPage(items[info.index])
|
||||
},
|
||||
onDoubleClick = DoubleTapZoom,
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding + PaddingValues(horizontal = sidePadding),
|
||||
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(if (withGaps) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -107,6 +106,7 @@ import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
@@ -151,7 +151,10 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
val density = LocalDensity.current
|
||||
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
|
||||
val ehTags = EhTagDatabase.takeIf { canTranslate }
|
||||
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
|
||||
fun getTranslation(tag: String) = ehTags?.run {
|
||||
getTranslation(TagNamespace.Artist.toPrefix(), tag)
|
||||
?: getTranslation(TagNamespace.Cosplayer.toPrefix(), tag)
|
||||
} ?: tag
|
||||
val allName = stringResource(R.string.download_all)
|
||||
val defaultName = stringResource(R.string.default_download_label_name)
|
||||
val unknownName = stringResource(R.string.unknown_artists)
|
||||
@@ -165,7 +168,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
when (label) {
|
||||
"" -> allName
|
||||
null -> emptyLabelName
|
||||
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
|
||||
else -> if (mode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -215,7 +218,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
fun closeSheet() = launch { drawerState.close() }
|
||||
TopAppBar(
|
||||
title = { Text(text = labelsStr) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
actions = {
|
||||
if (DownloadsFilterMode.CUSTOM == filterMode) {
|
||||
IconButton(
|
||||
@@ -296,7 +299,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = labelsListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
@@ -337,7 +340,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
snapshotFlow { dismissState.currentValue }.collect {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
runCatching {
|
||||
awaitPermissionOrCancel(confirmText = R.string.delete) {
|
||||
awaitConfirmationOrCancel(confirmText = R.string.delete) {
|
||||
Text(text = stringResource(R.string.delete_label, item))
|
||||
}
|
||||
}.onSuccess {
|
||||
@@ -380,7 +383,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = elevation,
|
||||
headlineContent = {
|
||||
val name = if (filterMode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
|
||||
val name = if (filterMode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
|
||||
Text("$name [${downloadsCount.getOrDefault(item, 0)}]")
|
||||
},
|
||||
trailingContent = editEnable.ifTrueThen {
|
||||
@@ -486,7 +489,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
onClick = {
|
||||
expanded = false
|
||||
launchIO {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = android.R.string.ok,
|
||||
dismissText = android.R.string.cancel,
|
||||
) {
|
||||
@@ -648,7 +651,9 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
|
||||
) {
|
||||
if (!selectMode) {
|
||||
onClick(Icons.Default.Shuffle) {
|
||||
withUIContext { navToReader(list.random().galleryInfo) }
|
||||
if (list.isNotEmpty()) {
|
||||
withUIContext { navToReader(list.random().galleryInfo) }
|
||||
}
|
||||
}
|
||||
onClick(Icons.AutoMirrored.Default.Sort) {
|
||||
val oldMode = SortMode.from(sortMode)
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.startDownload
|
||||
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
@@ -185,7 +186,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
|
||||
val localFavCount by localFavCountFlow.collectAsState(0)
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.collections)) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
)
|
||||
val scope = currentRecomposeScope
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -356,8 +357,9 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
|
||||
if (!selectMode) {
|
||||
if (isLocalFav) {
|
||||
onClick(Icons.Default.Shuffle) {
|
||||
val random = EhDB.randomLocalFav()
|
||||
withUIContext { navigate(random.asDst()) }
|
||||
EhDB.randomLocalFav()?.let { info ->
|
||||
withUIContext { navigate(info.asDst()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick(EhIcons.Default.GoTo) {
|
||||
@@ -383,7 +385,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
|
||||
}
|
||||
onClick(Icons.Default.Delete) {
|
||||
val info = checkedInfoMap.takeAndClear()
|
||||
awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
|
||||
awaitConfirmationOrCancel(title = R.string.delete_favorites_dialog_title) {
|
||||
Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
|
||||
}
|
||||
val srcCat = urlBuilder.favCat
|
||||
|
||||
@@ -211,7 +211,7 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
|
||||
val filterAdded = stringResource(R.string.filter_added)
|
||||
suspend fun showFilterCommenter(comment: GalleryComment) {
|
||||
val commenter = comment.user ?: return
|
||||
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
|
||||
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
|
||||
Filter(FilterMode.COMMENTER, commenter).remember()
|
||||
comments = comments.copy(comments = comments.comments.filterNot { it.user == commenter })
|
||||
showSnackbar(filterAdded)
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.text.TextUtils.TruncateAt.END
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ImageSearch
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SwapVerticalCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.LocalPinnableContainer
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import arrow.core.partially1
|
||||
import arrow.fx.coroutines.parMap
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.GalleryTagGroup
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.client.data.asGalleryDetail
|
||||
import com.hippo.ehviewer.client.data.findBaseInfo
|
||||
import com.hippo.ehviewer.coil.justDownload
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ktbuilder.launchIn
|
||||
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.confirmRemoveDownload
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
|
||||
import com.hippo.ehviewer.ui.getFavoriteIcon
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryTags
|
||||
import com.hippo.ehviewer.ui.main.TorrentList
|
||||
import com.hippo.ehviewer.ui.modifyFavorites
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.startDownload
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
|
||||
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
|
||||
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
|
||||
import com.hippo.ehviewer.ui.tools.getLimit
|
||||
import com.hippo.ehviewer.ui.tools.getOffset
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.bgWork
|
||||
import com.hippo.ehviewer.util.flattenForEach
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.downloadManager
|
||||
|
||||
context(CoroutineScope, DestinationsNavigator, DialogState, MainActivity, SnackbarHostState, SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryDetailContent(
|
||||
galleryInfo: GalleryInfo,
|
||||
contentPadding: PaddingValues,
|
||||
getDetailError: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
|
||||
val galleryDetail = galleryInfo.asGalleryDetail()
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val thumbColumns by Settings.thumbColumns.collectAsState()
|
||||
val readText = stringResource(R.string.read)
|
||||
val startPage by rememberInVM {
|
||||
EhDB.getReadProgressFlow(galleryInfo.gid)
|
||||
}.collectAsState(0)
|
||||
val readButtonText = if (startPage == 0) {
|
||||
readText
|
||||
} else {
|
||||
stringResource(R.string.read_from, startPage + 1)
|
||||
}
|
||||
val downloadState by DownloadManager.collectDownloadState(galleryInfo.gid)
|
||||
val downloadButtonText = when (downloadState) {
|
||||
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
|
||||
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
|
||||
else -> error("Invalid DownloadState!!!")
|
||||
}
|
||||
fun onReadButtonClick() {
|
||||
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
|
||||
navToReader(galleryInfo.findBaseInfo(), startPage)
|
||||
}
|
||||
}
|
||||
fun onCategoryChipClick() {
|
||||
val category = galleryInfo.category
|
||||
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
|
||||
return
|
||||
}
|
||||
navigate(ListUrlBuilder(category = category).asDst())
|
||||
}
|
||||
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
|
||||
val uploader = galleryInfo.uploader
|
||||
val disowned = uploader == "(Disowned)"
|
||||
if (uploader.isNullOrEmpty() || disowned) {
|
||||
return
|
||||
}
|
||||
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_UPLOADER, mKeyword = uploader).asDst())
|
||||
}
|
||||
|
||||
fun onGalleryInfoCardClick() {
|
||||
galleryDetail ?: return
|
||||
launch {
|
||||
dialog { cont ->
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
contentWindowInsets = { EmptyWindowInsets },
|
||||
) {
|
||||
GalleryInfoBottomSheet(galleryDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filterAdded = stringResource(R.string.filter_added)
|
||||
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
|
||||
val uploader = galleryInfo.uploader
|
||||
val disowned = uploader == "(Disowned)"
|
||||
if (uploader.isNullOrEmpty() || disowned) {
|
||||
return
|
||||
}
|
||||
launchIO {
|
||||
awaitConfirmationOrCancel {
|
||||
Text(text = stringResource(R.string.filter_the_uploader, uploader))
|
||||
}
|
||||
Filter(FilterMode.UPLOADER, uploader).remember()
|
||||
showSnackbar(filterAdded)
|
||||
}
|
||||
}
|
||||
fun onDownloadButtonClick() {
|
||||
galleryDetail ?: return
|
||||
if (DownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
|
||||
launchUI { startDownload(implicit<MainActivity>(), false, galleryDetail.galleryInfo) }
|
||||
} else {
|
||||
launch { confirmRemoveDownload(galleryDetail) }
|
||||
}
|
||||
}
|
||||
|
||||
val previews = galleryDetail?.let { collectPreviewItems(it, thumbColumns) }
|
||||
when (windowSizeClass.windowWidthSizeClass) {
|
||||
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(
|
||||
key = "header",
|
||||
span = { GridItemSpan(maxCurrentLineSpan) },
|
||||
contentType = "header",
|
||||
) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
|
||||
)
|
||||
}
|
||||
item(
|
||||
key = "body",
|
||||
span = { GridItemSpan(maxCurrentLineSpan) },
|
||||
contentType = "body",
|
||||
) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
Row {
|
||||
FilledTonalButton(
|
||||
onClick = ::onDownloadButtonClick,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
|
||||
) {
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
|
||||
} else if (galleryDetail != null) {
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previews != null) {
|
||||
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
|
||||
}
|
||||
}
|
||||
|
||||
WindowWidthSizeClass.EXPANDED -> FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(
|
||||
key = "header",
|
||||
span = { GridItemSpan(maxCurrentLineSpan) },
|
||||
contentType = "header",
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
Spacer(modifier = modifier.height(24.dp))
|
||||
FilledTonalButton(
|
||||
onClick = ::onDownloadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(
|
||||
key = "body",
|
||||
span = { GridItemSpan(maxCurrentLineSpan) },
|
||||
contentType = "body",
|
||||
) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
|
||||
} else if (galleryDetail != null) {
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previews != null) {
|
||||
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context(Context, CoroutineScope, DestinationsNavigator, DialogState, SnackbarHostState)
|
||||
@Composable
|
||||
fun BelowHeader(galleryDetail: GalleryDetail) {
|
||||
@Composable
|
||||
fun EhIconButton(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledTertiaryIconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Text(text = text)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
|
||||
val maxShowCount = 2
|
||||
val commentText = when {
|
||||
commentsList.isEmpty() -> stringResource(R.string.no_comments)
|
||||
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
|
||||
else -> stringResource(R.string.more_comment)
|
||||
}
|
||||
fun onNavigateToCommentScene() {
|
||||
navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
|
||||
}
|
||||
CrystalCard {
|
||||
commentsList.take(maxShowCount).forEach { item ->
|
||||
GalleryCommentCard(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
comment = item,
|
||||
onCardClick = ::onNavigateToCommentScene,
|
||||
onUserClick = ::onNavigateToCommentScene,
|
||||
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
|
||||
) {
|
||||
maxLines = 5
|
||||
ellipsize = END
|
||||
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = ::onNavigateToCommentScene),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(commentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun showNewerVersionDialog() {
|
||||
val items = galleryDetail.newerVersions.map {
|
||||
getString(R.string.newer_version_title, it.title, it.posted)
|
||||
}
|
||||
val selected = awaitSelectItem(items)
|
||||
val info = galleryDetail.newerVersions[selected]
|
||||
withUIContext {
|
||||
// Can't use GalleryInfoArgs as thumbKey is null
|
||||
navigate(info.gid asDstWith info.token)
|
||||
}
|
||||
}
|
||||
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (galleryDetail.newerVersions.isNotEmpty()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CrystalCard(
|
||||
onClick = { launchIO { showNewerVersionDialog() } },
|
||||
modifier = Modifier.fillMaxWidth().height(32.dp),
|
||||
) {
|
||||
}
|
||||
Text(text = stringResource(id = R.string.newer_version_available))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
|
||||
val favButtonText = if (favSlot != NOT_FAVORITED) {
|
||||
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
|
||||
} else {
|
||||
stringResource(id = R.string.not_favorited)
|
||||
}
|
||||
val favoritesLock = remember { MutatorMutex() }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
|
||||
val addSucceed = stringResource(R.string.add_to_favorite_success)
|
||||
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
|
||||
val addFailed = stringResource(R.string.add_to_favorite_failure)
|
||||
FilledTertiaryIconToggleButton(
|
||||
checked = favSlot != NOT_FAVORITED,
|
||||
onCheckedChange = {
|
||||
launchIO {
|
||||
favoritesLock.mutate {
|
||||
runSuspendCatching {
|
||||
modifyFavorites(galleryDetail.galleryInfo)
|
||||
}.onSuccess { add ->
|
||||
if (add) {
|
||||
showSnackbar(addSucceed)
|
||||
} else {
|
||||
showSnackbar(removeSucceed)
|
||||
}
|
||||
}.onFailure {
|
||||
// TODO: We don't know if it's add or remove
|
||||
showSnackbar(addFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Text(text = favButtonText)
|
||||
}
|
||||
EhIconButton(
|
||||
icon = Icons.Default.Search,
|
||||
text = stringResource(id = R.string.similar_gallery),
|
||||
onClick = {
|
||||
val keyword = EhUtils.extractTitle(galleryDetail.title)
|
||||
val artistTag = galleryDetail.tags.getArtistTag()
|
||||
if (null != keyword) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_NORMAL,
|
||||
mKeyword = "\"" + keyword + "\"",
|
||||
).asDst(),
|
||||
)
|
||||
} else if (artistTag != null) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_TAG,
|
||||
mKeyword = artistTag,
|
||||
).asDst(),
|
||||
)
|
||||
} else if (null != galleryDetail.uploader) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_UPLOADER,
|
||||
mKeyword = galleryDetail.uploader,
|
||||
).asDst(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
EhIconButton(
|
||||
icon = Icons.Default.ImageSearch,
|
||||
text = stringResource(id = R.string.search_cover),
|
||||
onClick = {
|
||||
val key = galleryDetail.thumbKey!!
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = key.substringAfterLast('/').substringBefore('-'),
|
||||
).asDst(),
|
||||
)
|
||||
},
|
||||
)
|
||||
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
|
||||
val permissionDenied = stringResource(R.string.permission_denied)
|
||||
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
|
||||
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
|
||||
val noTorrents = stringResource(R.string.no_torrents)
|
||||
val torrentResult = remember(galleryDetail) {
|
||||
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
|
||||
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, galleryDetail.gid, galleryDetail.token)
|
||||
}
|
||||
}
|
||||
suspend fun showTorrentDialog() {
|
||||
val torrentList = bgWork { torrentResult.await() }
|
||||
if (torrentList.isEmpty()) {
|
||||
showSnackbar(noTorrents)
|
||||
} else {
|
||||
val selected = showNoButton(false) {
|
||||
TorrentList(
|
||||
items = torrentList,
|
||||
onItemClick = { dismissWith(it) },
|
||||
)
|
||||
}
|
||||
val url = selected.url
|
||||
val name = "${selected.name}.torrent"
|
||||
val r = android.app.DownloadManager.Request(url.toUri())
|
||||
r.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
|
||||
)
|
||||
r.setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
|
||||
downloadManager.enqueue(r)
|
||||
showSnackbar(downloadTorrentStarted)
|
||||
}
|
||||
}
|
||||
EhIconButton(
|
||||
icon = Icons.Default.SwapVerticalCircle,
|
||||
text = torrentText,
|
||||
onClick = {
|
||||
launchIO {
|
||||
if (galleryDetail.torrentCount > 0) {
|
||||
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if (granted) {
|
||||
runSuspendCatching {
|
||||
showTorrentDialog()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
showSnackbar(downloadTorrentFailed)
|
||||
}
|
||||
} else {
|
||||
showSnackbar(permissionDenied)
|
||||
}
|
||||
} else {
|
||||
showSnackbar(noTorrents)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
|
||||
R.string.rating_text,
|
||||
getString(getRatingText(rating)),
|
||||
rating,
|
||||
ratingCount,
|
||||
)
|
||||
var ratingText by rememberSaveable {
|
||||
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
|
||||
}
|
||||
val rateSucceed = stringResource(R.string.rate_successfully)
|
||||
val rateFailed = stringResource(R.string.rate_failed)
|
||||
val signInFirst = stringResource(R.string.sign_in_first)
|
||||
fun showRateDialog() {
|
||||
launchIO {
|
||||
if (galleryDetail.apiUid < 0) {
|
||||
showSnackbar(signInFirst)
|
||||
return@launchIO
|
||||
}
|
||||
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
GalleryRatingBar(
|
||||
rating = expectedValue,
|
||||
onRatingChange = {
|
||||
expectedValue = it.coerceAtLeast(.5f)
|
||||
text = getRatingText(expectedValue)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
galleryDetail.runSuspendCatching {
|
||||
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
|
||||
}.onSuccess { result ->
|
||||
galleryDetail.apply {
|
||||
rating = result.rating
|
||||
ratingCount = result.ratingCount
|
||||
}
|
||||
ratingText = getAllRatingText(result.rating, result.ratingCount)
|
||||
showSnackbar(rateSucceed)
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
showSnackbar(rateFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
CrystalCard(onClick = ::showRateDialog) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
GalleryDetailRating(rating = galleryDetail.rating)
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
Text(text = ratingText)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
val tags = galleryDetail.tags
|
||||
if (tags.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.no_tags))
|
||||
}
|
||||
} else {
|
||||
val copy = stringResource(android.R.string.copy)
|
||||
val copyTrans = stringResource(R.string.copy_trans)
|
||||
val showDefine = stringResource(R.string.show_definition)
|
||||
val addFilter = stringResource(R.string.add_filter)
|
||||
val filterAdded = stringResource(R.string.filter_added)
|
||||
val upTag = stringResource(R.string.tag_vote_up)
|
||||
val downTag = stringResource(R.string.tag_vote_down)
|
||||
GalleryTags(
|
||||
tagGroups = tags,
|
||||
onTagClick = {
|
||||
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_TAG, mKeyword = it).asDst())
|
||||
},
|
||||
onTagLongClick = { translated, tag ->
|
||||
val index = tag.indexOf(':')
|
||||
val temp = if (index >= 0) {
|
||||
tag.substring(index + 1)
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
launchIO {
|
||||
awaitSelectAction {
|
||||
onSelect(copy) {
|
||||
addTextToClipboard(tag)
|
||||
}
|
||||
if (temp != translated) {
|
||||
onSelect(copyTrans) {
|
||||
addTextToClipboard(translated)
|
||||
}
|
||||
}
|
||||
onSelect(showDefine) {
|
||||
openBrowser(EhUrl.getTagDefinitionUrl(temp))
|
||||
}
|
||||
onSelect(addFilter) {
|
||||
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
|
||||
Filter(FilterMode.TAG, tag).remember()
|
||||
showSnackbar(filterAdded)
|
||||
}
|
||||
if (galleryDetail.apiUid >= 0) {
|
||||
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
|
||||
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
|
||||
}
|
||||
}()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (Settings.showComments) {
|
||||
GalleryDetailComment(galleryDetail.comments.comments)
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
|
||||
0 -> R.string.rating0
|
||||
1 -> R.string.rating1
|
||||
2 -> R.string.rating2
|
||||
3 -> R.string.rating3
|
||||
4 -> R.string.rating4
|
||||
5 -> R.string.rating5
|
||||
6 -> R.string.rating6
|
||||
7 -> R.string.rating7
|
||||
8 -> R.string.rating8
|
||||
9 -> R.string.rating9
|
||||
10 -> R.string.rating10
|
||||
else -> R.string.rating_none
|
||||
}
|
||||
|
||||
private fun List<GalleryTagGroup>.getArtistTag(): String? {
|
||||
for (tagGroup in this) {
|
||||
if (tagGroup.isNotEmpty()) {
|
||||
val namespace = tagGroup.groupName
|
||||
if (namespace == TagNamespace.Artist.value || namespace == TagNamespace.Cosplayer.value) {
|
||||
return "$namespace:${tagGroup[0].removePrefix("_")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Context.collectPreviewItems(detail: GalleryDetail, prefetchDistance: Int) = rememberInVM {
|
||||
val pageSize = detail.previewList.size
|
||||
val pages = detail.pages
|
||||
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
|
||||
Pager(
|
||||
PagingConfig(
|
||||
pageSize = pageSize,
|
||||
prefetchDistance = prefetchDistance,
|
||||
initialLoadSize = pageSize,
|
||||
jumpThreshold = 2 * pageSize,
|
||||
),
|
||||
) {
|
||||
object : PagingSource<Int, GalleryPreview>() {
|
||||
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
|
||||
val key = params.key ?: 0
|
||||
val up = getOffset(params, key, pages)
|
||||
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
|
||||
detail.runSuspendCatching {
|
||||
(up..end).filterNot { it in previewPagesMap }.map { it / pageSize }.toSet()
|
||||
.parMap(concurrency = Settings.multiThreadDownload) { page ->
|
||||
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
|
||||
EhEngine.getPreviewList(url).first
|
||||
}.flattenForEach {
|
||||
previewPagesMap[it.position] = it
|
||||
if (Settings.preloadThumbAggressively) {
|
||||
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
}.foldToLoadResult {
|
||||
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
|
||||
val prevK = if (up <= 0 || r.isEmpty()) null else up
|
||||
val nextK = if (end == pages - 1) null else end + 1
|
||||
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
|
||||
}
|
||||
}
|
||||
override val jumpingSupported = true
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
|
||||
private fun LazyGridScope.galleryPreview(data: LazyPagingItems<GalleryPreview>, onClick: (Int) -> Unit) {
|
||||
items(
|
||||
count = data.itemCount,
|
||||
key = data.itemKey(key = { item -> item.position }),
|
||||
contentType = { "preview" },
|
||||
) { index ->
|
||||
val item = data[index]
|
||||
EhPreviewItem(item, index) { onClick(index) }
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,92 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import android.text.TextUtils.TruncateAt.END
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.FolderZip
|
||||
import androidx.compose.material.icons.filled.ImageSearch
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.SwapVerticalCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.LocalPinnableContainer
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import arrow.core.partially1
|
||||
import coil3.imageLoader
|
||||
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
|
||||
import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryTagGroup
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.client.data.asGalleryDetail
|
||||
import com.hippo.ehviewer.client.data.findBaseInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.NoHAtHClientException
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.justDownload
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.spider.SpiderDen
|
||||
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.confirmRemoveDownload
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination
|
||||
import com.hippo.ehviewer.ui.getFavoriteIcon
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.ArchiveList
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryTags
|
||||
import com.hippo.ehviewer.ui.main.TorrentList
|
||||
import com.hippo.ehviewer.ui.modifyFavorites
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.startDownload
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
|
||||
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
|
||||
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberLambda
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.AppHelper
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.awaitActivityResult
|
||||
import com.hippo.ehviewer.util.bgWork
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.downloadManager
|
||||
@@ -185,47 +105,20 @@ data class TokenArgs(
|
||||
val page: Int = 0,
|
||||
) : GalleryDetailScreenArgs
|
||||
|
||||
@StringRes
|
||||
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
|
||||
0 -> R.string.rating0
|
||||
1 -> R.string.rating1
|
||||
2 -> R.string.rating2
|
||||
3 -> R.string.rating3
|
||||
4 -> R.string.rating4
|
||||
5 -> R.string.rating5
|
||||
6 -> R.string.rating6
|
||||
7 -> R.string.rating7
|
||||
8 -> R.string.rating8
|
||||
9 -> R.string.rating9
|
||||
10 -> R.string.rating10
|
||||
else -> R.string.rating_none
|
||||
}
|
||||
|
||||
private fun List<GalleryTagGroup>.getArtistTag(): String? {
|
||||
val namespace = TagNamespace.Artist.value
|
||||
for (tagGroup in this) {
|
||||
if (tagGroup.groupName == namespace && tagGroup.size > 0) {
|
||||
return "$namespace:${tagGroup[0].removePrefix("_")}"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var galleryInfo by remember {
|
||||
var galleryInfo by rememberInVM {
|
||||
val casted = args as? GalleryInfoArgs
|
||||
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
|
||||
}
|
||||
val (gid, token) = remember {
|
||||
when (args) {
|
||||
is GalleryInfoArgs -> args.galleryInfo.run { gid to token }
|
||||
is GalleryInfoArgs -> with(args.galleryInfo) { gid to token }
|
||||
is TokenArgs -> args.gid to args.token
|
||||
}
|
||||
}
|
||||
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
|
||||
val activity = remember { findActivity<MainActivity>() }
|
||||
var showReadAction by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(args, galleryInfo) {
|
||||
if (showReadAction) {
|
||||
@@ -244,15 +137,10 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
}
|
||||
}
|
||||
with(activity) {
|
||||
ProvideAssistContent(galleryDetailUrl)
|
||||
}
|
||||
ProvideAssistContent(galleryDetailUrl)
|
||||
var getDetailError by rememberSaveable { mutableStateOf("") }
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
val voteSuccess = stringResource(R.string.tag_vote_successfully)
|
||||
val voteFailed = stringResource(R.string.vote_failed)
|
||||
|
||||
if (galleryInfo !is GalleryDetail && getDetailError.isBlank()) {
|
||||
LaunchedEffect(Unit) {
|
||||
val galleryDetail = galleryDetailCache[gid]
|
||||
@@ -278,20 +166,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
|
||||
runSuspendCatching {
|
||||
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
|
||||
}.onSuccess { result ->
|
||||
if (result != null) {
|
||||
showSnackbar(result)
|
||||
} else {
|
||||
showSnackbar(voteSuccess)
|
||||
}
|
||||
}.onFailure {
|
||||
showSnackbar(voteFailed)
|
||||
}
|
||||
}
|
||||
|
||||
val archiveResult = remember(galleryInfo) {
|
||||
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
|
||||
val detail = galleryInfo as GalleryDetail
|
||||
@@ -330,7 +204,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
val name = "$gid-${EhUtils.getSuitableTitle(galleryDetail)}.zip"
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
startActivity(intent)
|
||||
withUIContext { addTextToClipboard(name, true) }
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val r = DownloadManager.Request(uri)
|
||||
@@ -364,592 +238,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
}
|
||||
|
||||
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
|
||||
|
||||
fun navigateToPreview(nextPage: Boolean = false) {
|
||||
(galleryInfo as? GalleryDetail)?.let {
|
||||
navigator.navigate(GalleryPreviewScreenDestination(it, nextPage))
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyGridScope.galleryDetailPreview(gd: GalleryDetail) {
|
||||
val previewList = gd.previewList
|
||||
items(previewList) {
|
||||
EhPreviewItem(
|
||||
galleryPreview = it,
|
||||
position = it.position,
|
||||
onClick = { navToReader(gd.galleryInfo, it.position) },
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
val footerText = if (gd.previewPages <= 0 || previewList.isEmpty()) {
|
||||
stringResource(R.string.no_previews)
|
||||
} else if (gd.previewPages == 1) {
|
||||
stringResource(R.string.no_more_previews)
|
||||
} else {
|
||||
stringResource(R.string.more_previews)
|
||||
}
|
||||
TextButton(onClick = ::navigateToPreview.partially1(true)) {
|
||||
Text(footerText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BelowHeader(galleryDetail: GalleryDetail) {
|
||||
@Composable
|
||||
fun EhIconButton(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledTertiaryIconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Text(text = text)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
|
||||
val maxShowCount = 2
|
||||
val commentText = when {
|
||||
commentsList.isEmpty() -> stringResource(R.string.no_comments)
|
||||
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
|
||||
else -> stringResource(R.string.more_comment)
|
||||
}
|
||||
fun onNavigateToCommentScene() {
|
||||
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
|
||||
}
|
||||
CrystalCard {
|
||||
commentsList.take(maxShowCount).forEach { item ->
|
||||
GalleryCommentCard(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
comment = item,
|
||||
onCardClick = ::onNavigateToCommentScene,
|
||||
onUserClick = ::onNavigateToCommentScene,
|
||||
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
|
||||
) {
|
||||
maxLines = 5
|
||||
ellipsize = END
|
||||
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = ::onNavigateToCommentScene),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(commentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun showNewerVersionDialog() {
|
||||
val items = galleryDetail.newerVersions.map {
|
||||
getString(R.string.newer_version_title, it.title, it.posted)
|
||||
}
|
||||
val selected = awaitSelectItem(items)
|
||||
val info = galleryDetail.newerVersions[selected]
|
||||
withUIContext {
|
||||
// Can't use GalleryInfoArgs as thumbKey is null
|
||||
navigator.navigate(info.gid asDstWith info.token)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (galleryDetail.newerVersions.isNotEmpty()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CrystalCard(
|
||||
onClick = { launchIO { showNewerVersionDialog() } },
|
||||
modifier = Modifier.fillMaxWidth().height(32.dp),
|
||||
) {
|
||||
}
|
||||
Text(text = stringResource(id = R.string.newer_version_available))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
|
||||
val favButtonText = if (favSlot != NOT_FAVORITED) {
|
||||
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
|
||||
} else {
|
||||
stringResource(id = R.string.not_favorited)
|
||||
}
|
||||
val favoritesLock = remember { MutatorMutex() }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
|
||||
val addSucceed = stringResource(R.string.add_to_favorite_success)
|
||||
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
|
||||
val addFailed = stringResource(R.string.add_to_favorite_failure)
|
||||
FilledTertiaryIconToggleButton(
|
||||
checked = favSlot != NOT_FAVORITED,
|
||||
onCheckedChange = {
|
||||
launchIO {
|
||||
favoritesLock.mutate {
|
||||
runSuspendCatching {
|
||||
modifyFavorites(galleryDetail.galleryInfo)
|
||||
}.onSuccess { add ->
|
||||
if (add) {
|
||||
showSnackbar(addSucceed)
|
||||
} else {
|
||||
showSnackbar(removeSucceed)
|
||||
}
|
||||
}.onFailure {
|
||||
// TODO: We don't know if it's add or remove
|
||||
showSnackbar(addFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Text(text = favButtonText)
|
||||
}
|
||||
EhIconButton(
|
||||
icon = Icons.Default.Search,
|
||||
text = stringResource(id = R.string.similar_gallery),
|
||||
onClick = {
|
||||
val keyword = EhUtils.extractTitle(galleryDetail.title)
|
||||
val artistTag = galleryDetail.tags.getArtistTag()
|
||||
if (null != keyword) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_NORMAL,
|
||||
mKeyword = "\"" + keyword + "\"",
|
||||
).asDst(),
|
||||
)
|
||||
} else if (artistTag != null) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_TAG,
|
||||
mKeyword = artistTag,
|
||||
).asDst(),
|
||||
)
|
||||
} else if (null != galleryDetail.uploader) {
|
||||
navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_UPLOADER,
|
||||
mKeyword = galleryDetail.uploader,
|
||||
).asDst(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
EhIconButton(
|
||||
icon = Icons.Default.ImageSearch,
|
||||
text = stringResource(id = R.string.search_cover),
|
||||
onClick = {
|
||||
val key = galleryDetail.thumbKey!!
|
||||
navigator.navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = key.substringAfterLast('/').substringBefore('-'),
|
||||
).asDst(),
|
||||
)
|
||||
},
|
||||
)
|
||||
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
|
||||
val permissionDenied = stringResource(R.string.permission_denied)
|
||||
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
|
||||
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
|
||||
val noTorrents = stringResource(R.string.no_torrents)
|
||||
val torrentResult = remember(galleryDetail) {
|
||||
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
|
||||
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
|
||||
}
|
||||
}
|
||||
suspend fun showTorrentDialog() {
|
||||
val torrentList = bgWork { torrentResult.await() }
|
||||
if (torrentList.isEmpty()) {
|
||||
showSnackbar(noTorrents)
|
||||
} else {
|
||||
val selected = showNoButton(false) {
|
||||
TorrentList(
|
||||
items = torrentList,
|
||||
onItemClick = { dismissWith(it) },
|
||||
)
|
||||
}
|
||||
val url = selected.url
|
||||
val name = "${selected.name}.torrent"
|
||||
val r = DownloadManager.Request(url.toUri())
|
||||
r.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
|
||||
)
|
||||
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
|
||||
downloadManager.enqueue(r)
|
||||
showSnackbar(downloadTorrentStarted)
|
||||
}
|
||||
}
|
||||
EhIconButton(
|
||||
icon = Icons.Default.SwapVerticalCircle,
|
||||
text = torrentText,
|
||||
onClick = {
|
||||
launchIO {
|
||||
if (galleryDetail.torrentCount > 0) {
|
||||
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if (granted) {
|
||||
runSuspendCatching {
|
||||
showTorrentDialog()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
showSnackbar(downloadTorrentFailed)
|
||||
}
|
||||
} else {
|
||||
showSnackbar(permissionDenied)
|
||||
}
|
||||
} else {
|
||||
showSnackbar(noTorrents)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
|
||||
R.string.rating_text,
|
||||
getString(getRatingText(rating)),
|
||||
rating,
|
||||
ratingCount,
|
||||
)
|
||||
var ratingText by rememberSaveable {
|
||||
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
|
||||
}
|
||||
val rateSucceed = stringResource(R.string.rate_successfully)
|
||||
val rateFailed = stringResource(R.string.rate_failed)
|
||||
fun showRateDialog() {
|
||||
launchIO {
|
||||
if (galleryDetail.apiUid < 0) {
|
||||
showSnackbar(signInFirst)
|
||||
return@launchIO
|
||||
}
|
||||
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
GalleryRatingBar(
|
||||
rating = expectedValue,
|
||||
onRatingChange = {
|
||||
expectedValue = it.coerceAtLeast(.5f)
|
||||
text = getRatingText(expectedValue)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
galleryDetail.runSuspendCatching {
|
||||
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
|
||||
}.onSuccess { result ->
|
||||
galleryInfo = galleryDetail.apply {
|
||||
rating = result.rating
|
||||
ratingCount = result.ratingCount
|
||||
}
|
||||
ratingText = getAllRatingText(result.rating, result.ratingCount)
|
||||
showSnackbar(rateSucceed)
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
showSnackbar(rateFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
CrystalCard(onClick = ::showRateDialog) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
GalleryDetailRating(rating = galleryDetail.rating)
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
Text(text = ratingText)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
val tags = galleryDetail.tags
|
||||
if (tags.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.no_tags))
|
||||
}
|
||||
} else {
|
||||
val copy = stringResource(android.R.string.copy)
|
||||
val copyTrans = stringResource(R.string.copy_trans)
|
||||
val showDefine = stringResource(R.string.show_definition)
|
||||
val addFilter = stringResource(R.string.add_filter)
|
||||
val filterAdded = stringResource(R.string.filter_added)
|
||||
val upTag = stringResource(R.string.tag_vote_up)
|
||||
val downTag = stringResource(R.string.tag_vote_down)
|
||||
GalleryTags(
|
||||
tagGroups = tags,
|
||||
onTagClick = {
|
||||
val lub = ListUrlBuilder()
|
||||
lub.mode = ListUrlBuilder.MODE_TAG
|
||||
lub.keyword = it
|
||||
navigate(lub.asDst())
|
||||
},
|
||||
onTagLongClick = { translated, tag ->
|
||||
val index = tag.indexOf(':')
|
||||
val temp = if (index >= 0) {
|
||||
tag.substring(index + 1)
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
launchIO {
|
||||
awaitSelectAction {
|
||||
with(activity) {
|
||||
onSelect(copy) {
|
||||
addTextToClipboard(tag)
|
||||
}
|
||||
if (temp != translated) {
|
||||
onSelect(copyTrans) {
|
||||
addTextToClipboard(translated)
|
||||
}
|
||||
}
|
||||
onSelect(showDefine) {
|
||||
openBrowser(EhUrl.getTagDefinitionUrl(temp))
|
||||
}
|
||||
onSelect(addFilter) {
|
||||
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
|
||||
Filter(FilterMode.TAG, tag).remember()
|
||||
showSnackbar(filterAdded)
|
||||
}
|
||||
if (galleryDetail.apiUid >= 0) {
|
||||
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
|
||||
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (Settings.showComments) {
|
||||
GalleryDetailComment(galleryDetail.comments.comments)
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailContent(
|
||||
galleryInfo: GalleryInfo,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val galleryDetail = galleryInfo.asGalleryDetail()
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val thumbColumns by Settings.thumbColumns.collectAsState()
|
||||
val readText = stringResource(R.string.read)
|
||||
val startPage by rememberInVM {
|
||||
EhDB.getReadProgressFlow(galleryInfo.gid)
|
||||
}.collectAsState(0)
|
||||
val readButtonText = if (startPage == 0) {
|
||||
readText
|
||||
} else {
|
||||
stringResource(R.string.read_from, startPage + 1)
|
||||
}
|
||||
val downloadState by EhDownloadManager.collectDownloadState(gid)
|
||||
val downloadButtonText = when (downloadState) {
|
||||
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
|
||||
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
|
||||
else -> error("Invalid DownloadState!!!")
|
||||
}
|
||||
fun onReadButtonClick() {
|
||||
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
|
||||
navToReader(galleryInfo.findBaseInfo(), startPage)
|
||||
}
|
||||
}
|
||||
fun onCategoryChipClick() {
|
||||
val category = galleryInfo.category
|
||||
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
|
||||
return
|
||||
}
|
||||
val lub = ListUrlBuilder(category = category)
|
||||
navigator.navigate(lub.asDst())
|
||||
}
|
||||
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
|
||||
val uploader = galleryInfo.uploader
|
||||
val disowned = uploader == "(Disowned)"
|
||||
if (uploader.isNullOrEmpty() || disowned) {
|
||||
return
|
||||
}
|
||||
val lub = ListUrlBuilder()
|
||||
lub.mode = ListUrlBuilder.MODE_UPLOADER
|
||||
lub.keyword = uploader
|
||||
navigator.navigate(lub.asDst())
|
||||
}
|
||||
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
if (showBottomSheet && galleryDetail != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||
) {
|
||||
GalleryInfoBottomSheet(galleryDetail, navigator)
|
||||
}
|
||||
}
|
||||
|
||||
fun onGalleryInfoCardClick() {
|
||||
showBottomSheet = true
|
||||
}
|
||||
|
||||
val filterAdded = stringResource(R.string.filter_added)
|
||||
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
|
||||
val uploader = galleryInfo.uploader
|
||||
val disowned = uploader == "(Disowned)"
|
||||
if (uploader.isNullOrEmpty() || disowned) {
|
||||
return
|
||||
}
|
||||
launchIO {
|
||||
awaitPermissionOrCancel {
|
||||
Text(text = stringResource(R.string.filter_the_uploader, uploader))
|
||||
}
|
||||
Filter(FilterMode.UPLOADER, uploader).remember()
|
||||
showSnackbar(filterAdded)
|
||||
}
|
||||
}
|
||||
val onDownloadButtonClick = rememberLambda(galleryInfo) {
|
||||
galleryDetail ?: return@rememberLambda
|
||||
if (EhDownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
|
||||
launchUI { startDownload(activity, false, galleryDetail.galleryInfo) }
|
||||
} else {
|
||||
launch { confirmRemoveDownload(galleryDetail) }
|
||||
}
|
||||
}
|
||||
|
||||
when (windowSizeClass.windowWidthSizeClass) {
|
||||
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
Row {
|
||||
FilledTonalButton(
|
||||
onClick = onDownloadButtonClick,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
|
||||
) {
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
|
||||
} else if (galleryDetail != null) {
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (galleryDetail != null) {
|
||||
galleryDetailPreview(galleryDetail)
|
||||
}
|
||||
}
|
||||
|
||||
WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
Spacer(modifier = modifier.height(24.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onDownloadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
|
||||
} else if (galleryDetail != null) {
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (galleryDetail != null) {
|
||||
galleryDetailPreview(galleryDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
@@ -980,7 +268,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
AppHelper.share(activity, galleryDetailUrl)
|
||||
AppHelper.share(implicit<MainActivity>(), galleryDetailUrl)
|
||||
// In case the link is copied to the clipboard
|
||||
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
|
||||
},
|
||||
@@ -1040,7 +328,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
dropdown = false
|
||||
val gd = galleryInfo as? GalleryDetail ?: return@DropdownMenuItem
|
||||
launchIO {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
title = R.string.clear_image_cache,
|
||||
) {
|
||||
@@ -1071,7 +359,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
val downloadInfo = EhDownloadManager.getDownloadInfo(gid)
|
||||
val canExport = downloadInfo?.state == DownloadInfo.STATE_FINISH
|
||||
if (!canExport) {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
showCancelButton = false,
|
||||
text = { Text(text = stringResource(id = R.string.download_gallery_first)) },
|
||||
)
|
||||
@@ -1112,6 +400,8 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
GalleryDetailContent(
|
||||
galleryInfo = gi,
|
||||
contentPadding = it,
|
||||
getDetailError = getDetailError,
|
||||
onRetry = { getDetailError = "" },
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
)
|
||||
} else if (getDetailError.isNotBlank()) {
|
||||
@@ -1126,3 +416,18 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context(Context, SnackbarHostState)
|
||||
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
|
||||
runSuspendCatching {
|
||||
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
|
||||
}.onSuccess { result ->
|
||||
if (result != null) {
|
||||
showSnackbar(result)
|
||||
} else {
|
||||
showSnackbar(getString(R.string.tag_vote_successfully))
|
||||
}
|
||||
}.onFailure {
|
||||
showSnackbar(getString(R.string.vote_failed))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -126,6 +125,8 @@ import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.main.ImageSearch
|
||||
import com.hippo.ehviewer.ui.main.SearchFilter
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
@@ -283,7 +284,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
|
||||
ProvideSideSheetContent { sheetState ->
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.toplist)) },
|
||||
windowInsets = WindowInsets(0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
)
|
||||
toplists.forEach { (name, keyword) ->
|
||||
ListItem(
|
||||
@@ -307,7 +308,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
launch {
|
||||
awaitPermissionOrCancel(title = R.string.quick_search, showCancelButton = false) {
|
||||
awaitConfirmationOrCancel(title = R.string.quick_search, showCancelButton = false) {
|
||||
Text(text = stringResource(id = R.string.add_quick_search_tip))
|
||||
}
|
||||
}
|
||||
@@ -364,7 +365,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets(0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val quickSearchListState = rememberLazyListState()
|
||||
@@ -374,7 +375,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = quickSearchListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
@@ -392,7 +393,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
|
||||
snapshotFlow { dismissState.currentValue }.collect { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
runCatching {
|
||||
awaitPermissionOrCancel(confirmText = R.string.delete) {
|
||||
awaitConfirmationOrCancel(confirmText = R.string.delete) {
|
||||
Text(text = stringResource(R.string.delete_quick_search, item.name))
|
||||
}
|
||||
}.onSuccess {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import arrow.fx.coroutines.parMap
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.coil.justDownload
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ktbuilder.launchIn
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
|
||||
import com.hippo.ehviewer.ui.tools.getLimit
|
||||
import com.hippo.ehviewer.ui.tools.getOffset
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.flattenForEach
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AnimatedVisibilityScope.GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val pgSize = detail.previewList.size
|
||||
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
|
||||
val thumbColumns by Settings.thumbColumns.collectAsState()
|
||||
val data = rememberInVM {
|
||||
val pages = detail.pages
|
||||
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
|
||||
Pager(
|
||||
PagingConfig(
|
||||
pageSize = pgSize,
|
||||
prefetchDistance = thumbColumns,
|
||||
initialLoadSize = pgSize,
|
||||
jumpThreshold = 2 * pgSize,
|
||||
),
|
||||
) {
|
||||
object : PagingSource<Int, GalleryPreview>() {
|
||||
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
|
||||
val key = params.key ?: 0
|
||||
val up = getOffset(params, key, pages)
|
||||
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
|
||||
detail.runSuspendCatching {
|
||||
(up..end).filterNot { it in previewPagesMap }.map { it / pgSize }.toSet()
|
||||
.parMap(concurrency = Settings.multiThreadDownload) { page ->
|
||||
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
|
||||
EhEngine.getPreviewList(url).first
|
||||
}.flattenForEach {
|
||||
previewPagesMap[it.position] = it
|
||||
if (Settings.preloadThumbAggressively) {
|
||||
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
}.foldToLoadResult {
|
||||
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
|
||||
val prevK = if (up <= 0 || r.isEmpty()) null else up
|
||||
val nextK = if (end == pages - 1) null else end + 1
|
||||
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
|
||||
}
|
||||
}
|
||||
override val jumpingSupported = true
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.gallery_previews)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { popBackStack() }) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehaviour,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
|
||||
state = state,
|
||||
contentPadding = paddingValues,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(
|
||||
count = data.itemCount,
|
||||
key = data.itemKey(key = { item -> item.position }),
|
||||
contentType = data.itemContentType(),
|
||||
) { index ->
|
||||
val item = data[index]
|
||||
EhPreviewItem(item, index) { navToReader(detail.galleryInfo, index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = co
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
launch {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ fun SearchBarScreen(
|
||||
|
||||
fun deleteKeyword(keyword: String) {
|
||||
scope.launch {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.delete,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.delete_search_history, keyword))
|
||||
|
||||
@@ -139,7 +139,7 @@ fun AboutScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
|
||||
suspend fun DialogState.showNewVersion(context: Context, release: Release) {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.download,
|
||||
title = R.string.new_version_available,
|
||||
) {
|
||||
|
||||
@@ -5,7 +5,10 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||
import android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -35,12 +38,11 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.rememberedAccessor
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
@@ -48,14 +50,16 @@ import com.hippo.ehviewer.util.displayPath
|
||||
import com.hippo.ehviewer.util.getAppLanguage
|
||||
import com.hippo.ehviewer.util.getLanguages
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastV
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.ehviewer.util.setAppLanguage
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.jamal.composeprefs3.ui.prefs.DropDownPref
|
||||
import com.jamal.composeprefs3.ui.prefs.SwitchPref
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -64,6 +68,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@@ -73,7 +79,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
|
||||
val dialogState = LocalDialogState.current
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -94,6 +99,26 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
|
||||
value = Settings::saveParseErrorBody,
|
||||
)
|
||||
val stripAds = Settings.stripExtraneousAds.asMutableState()
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_block_extraneous_ads),
|
||||
value = stripAds.rememberedAccessor,
|
||||
)
|
||||
AnimatedVisibility(visible = stripAds.value) {
|
||||
LauncherPreference(
|
||||
title = stringResource(id = R.string.settings_ads_placeholder),
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
key = PickVisualMediaRequest(mediaType = ImageOnly),
|
||||
) { uri ->
|
||||
withIOContext {
|
||||
if (uri != null) {
|
||||
uri.toOkioPath() sendTo AdsPlaceholderFile
|
||||
} else {
|
||||
AdsPlaceholderFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_advanced_save_crash_log),
|
||||
summary = stringResource(id = R.string.settings_advanced_save_crash_log_summary),
|
||||
@@ -171,26 +196,17 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.animate_items),
|
||||
summary = stringResource(id = R.string.animate_items_summary),
|
||||
)
|
||||
var userAgent by Settings::userAgent.observed
|
||||
val userAgentTitle = stringResource(id = R.string.user_agent)
|
||||
Preference(
|
||||
title = userAgentTitle,
|
||||
summary = userAgent,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
userAgent = dialogState.awaitInputText(
|
||||
initial = userAgent,
|
||||
title = userAgentTitle,
|
||||
).trim().ifBlank { null } ?: CHROME_USER_AGENT
|
||||
}
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.desktop_site),
|
||||
summary = stringResource(id = R.string.desktop_site_summary),
|
||||
value = Settings::desktopSite,
|
||||
)
|
||||
val exportFailed = stringResource(id = R.string.settings_advanced_export_data_failed)
|
||||
val now = ReadableTime.getFilenamableTime()
|
||||
LauncherPreference(
|
||||
title = stringResource(id = R.string.settings_advanced_export_data),
|
||||
summary = stringResource(id = R.string.settings_advanced_export_data_summary),
|
||||
contract = ActivityResultContracts.CreateDocument("application/vnd.sqlite3"),
|
||||
key = if (isAtLeastV) now else "$now.db",
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||
key = ReadableTime.getFilenamableTime() + ".db",
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
context.runCatching {
|
||||
@@ -209,7 +225,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.settings_advanced_import_data),
|
||||
summary = stringResource(id = R.string.settings_advanced_import_data_summary),
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
key = if (isAtLeastV) "application/vnd.sqlite3" else "application/octet-stream",
|
||||
key = "application/octet-stream",
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
context.runCatching {
|
||||
@@ -284,3 +300,5 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val AdsPlaceholderFile = appCtx.filesDir.toOkioPath() / "AdsPlaceholder"
|
||||
@@ -64,7 +64,6 @@ import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
@@ -99,10 +98,8 @@ fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = c
|
||||
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
val path = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)).toOkioPath()
|
||||
check(path.isDirectory) { "$path is not a directory" }
|
||||
keepNoMediaFileStatus(path) // Check if the directory is writable
|
||||
downloadLocationState = path
|
||||
launchNonCancellable {
|
||||
keepNoMediaFileStatus(downloadLocationState)
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
launchSnackBar(cannotGetDownloadLocation)
|
||||
@@ -260,7 +257,7 @@ fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = c
|
||||
}
|
||||
val list = downloadLocation.list().filter(::isRedundant)
|
||||
if (list.isNotEmpty()) {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.delete,
|
||||
title = R.string.settings_download_clean_redundancy,
|
||||
) {
|
||||
|
||||
@@ -115,7 +115,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
val cookies = EhCookieStore.getIdentityCookies()
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.settings_eh_sign_out,
|
||||
dismissText = R.string.settings_eh_clear_igneous,
|
||||
showCancelButton = cookies.last().second != null,
|
||||
@@ -160,7 +160,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val summary by rememberUpdatedState(
|
||||
result?.run {
|
||||
stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
|
||||
when (limits.maximum) {
|
||||
-1 -> stringResource(id = R.string.image_limits_restricted)
|
||||
0 -> stringResource(id = R.string.image_limits_normal)
|
||||
else -> stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
|
||||
}
|
||||
} ?: placeholder,
|
||||
)
|
||||
suspend fun getImageLimits() {
|
||||
@@ -181,7 +185,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
summary = summary,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.reset,
|
||||
title = R.string.image_limits,
|
||||
confirmButtonEnabled = result?.run { limits.resetCost != 0 } ?: false,
|
||||
@@ -190,12 +194,16 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
Text(text = it)
|
||||
} ?: result?.let { (limits, funds) ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { limits.current.toFloat() / limits.maximum },
|
||||
modifier = Modifier.height(12.dp).fillMaxWidth(),
|
||||
)
|
||||
if (limits.maximum > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { limits.current.toFloat() / limits.maximum },
|
||||
modifier = Modifier.height(12.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Text(text = summary)
|
||||
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
|
||||
if (limits.resetCost > 0) {
|
||||
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
|
||||
}
|
||||
Text(text = stringResource(id = R.string.current_funds))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -22,13 +22,13 @@ import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -127,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
readOnly = true,
|
||||
value = type,
|
||||
onValueChange = {},
|
||||
@@ -193,7 +193,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
title = R.string.filter,
|
||||
showCancelButton = false,
|
||||
) {
|
||||
@@ -251,7 +251,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dialogState.awaitPermissionOrCancel(confirmText = R.string.delete) {
|
||||
dialogState.awaitConfirmationOrCancel(confirmText = R.string.delete) {
|
||||
Text(text = stringResource(id = R.string.delete_filter, filter.text))
|
||||
}
|
||||
filter.forget {
|
||||
|
||||
@@ -89,7 +89,7 @@ fun PrivacyScreen(navigator: DestinationsNavigator) {
|
||||
summary = stringResource(id = R.string.clear_search_history_summary),
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
title = R.string.clear_search_history_confirm,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.scrollbar.LocalScrollbarStyle
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -19,8 +18,7 @@ import com.hippo.ehviewer.ui.tools.scrollbarStyle
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
|
||||
@Composable
|
||||
fun EhTheme(content: @Composable () -> Unit) {
|
||||
val useDarkTheme = isSystemInDarkTheme()
|
||||
fun EhTheme(useDarkTheme: Boolean, content: @Composable () -> Unit) {
|
||||
val amoled by Settings.blackDarkTheme.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = if (useDarkTheme) {
|
||||
|
||||
@@ -272,7 +272,7 @@ class DialogState {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitPermissionOrCancel(
|
||||
suspend fun awaitConfirmationOrCancel(
|
||||
@StringRes confirmText: Int = android.R.string.ok,
|
||||
@StringRes dismissText: Int = android.R.string.cancel,
|
||||
@StringRes title: Int? = null,
|
||||
@@ -459,7 +459,7 @@ class DialogState {
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
LazyColumn {
|
||||
FastScrollLazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
CheckableItem(
|
||||
text = text,
|
||||
@@ -495,7 +495,7 @@ class DialogState {
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
LazyColumn {
|
||||
FastScrollLazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
CheckableItem(
|
||||
text = text,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -29,10 +29,10 @@ fun DropdownFilterChip(
|
||||
label = {
|
||||
Text(text = if (selectedItemIndex == 0) label else menuItems[selectedItemIndex])
|
||||
},
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchAnchorWidth = false) {
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchTextFieldWidth = false) {
|
||||
menuItems.forEachIndexed { index, item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item) },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
@@ -11,6 +10,8 @@ import androidx.compose.foundation.scrollbar.rememberScrollbarAdapter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -24,7 +25,7 @@ fun FastScrollLazyColumn(
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
Box {
|
||||
Layout({
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
@@ -38,8 +39,13 @@ fun FastScrollLazyColumn(
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(state),
|
||||
isScrollInProgress = state.isScrollInProgress,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}) { measurables, constraints ->
|
||||
val (column, scrollbar) = measurables.map { it.measure(constraints) }
|
||||
layout(column.width, column.height) {
|
||||
column.place(IntOffset.Zero)
|
||||
scrollbar.placeRelative(column.width - scrollbar.width, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
|
||||
if (condition) block() else this
|
||||
|
||||
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
|
||||
|
||||
@JvmInline
|
||||
@Stable
|
||||
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
|
||||
|
||||
context(TransitionsVisibilityScope)
|
||||
@Composable
|
||||
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
|
||||
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun Modifier.sharedBounds(
|
||||
key: String,
|
||||
enter: EnterTransition = fadeIn(),
|
||||
exit: ExitTransition = fadeOut(),
|
||||
) = scopes.fold(this) { modifier, scope ->
|
||||
modifier.sharedBounds(
|
||||
rememberSharedContentState("$key + ${scope.transition.label}"),
|
||||
scope,
|
||||
enter,
|
||||
exit,
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.cache
|
||||
import androidx.compose.runtime.currentComposer
|
||||
|
||||
@Composable
|
||||
fun rememberLambda(calculation: @DisallowComposableCalls () -> Unit): () -> Unit =
|
||||
currentComposer.cache(false) { calculation }
|
||||
|
||||
@Composable
|
||||
fun rememberLambda(
|
||||
key1: Any?,
|
||||
calculation: @DisallowComposableCalls () -> Unit,
|
||||
): () -> Unit = currentComposer.cache(currentComposer.changed(key1)) { calculation }
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import arrow.atomic.AtomicInt
|
||||
|
||||
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
|
||||
|
||||
@JvmInline
|
||||
@Stable
|
||||
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
|
||||
|
||||
context(TransitionsVisibilityScope)
|
||||
@Composable
|
||||
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
|
||||
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
fun Modifier.sharedBounds(
|
||||
key: String,
|
||||
enter: EnterTransition = fadeIn(),
|
||||
exit: ExitTransition = fadeOut(),
|
||||
) = remember(key) { summon(key) }.let { node ->
|
||||
DisposableEffect(key) {
|
||||
onDispose {
|
||||
dispose(node)
|
||||
}
|
||||
}
|
||||
scopes.fold(this) { modifier, scope ->
|
||||
modifier.sharedBounds(
|
||||
rememberSharedContentState("${node.syntheticKey} + ${scope.transition.label}"),
|
||||
scope,
|
||||
enter,
|
||||
exit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SETNode(
|
||||
val contentKey: String,
|
||||
val uniqueID: String,
|
||||
)
|
||||
|
||||
val SETNode.syntheticKey: String
|
||||
get() = contentKey + uniqueID
|
||||
|
||||
val listThumbGenerator = SETNodeGenerator()
|
||||
val detailThumbGenerator = SETNodeGenerator()
|
||||
|
||||
fun initSETConnection() {
|
||||
listThumbGenerator connectTo detailThumbGenerator
|
||||
}
|
||||
|
||||
private val atomicIncId = AtomicInt()
|
||||
|
||||
class SETNodeGenerator {
|
||||
private val tracker = hashMapOf<String, SETNode>()
|
||||
private val opposites = mutableListOf<SETNodeGenerator>()
|
||||
|
||||
fun summon(contentKey: String): SETNode {
|
||||
val node = opposites.firstNotNullOfOrNull { it.tracker[contentKey] } ?: SETNode(contentKey = contentKey, uniqueID = "${atomicIncId.getAndIncrement()}")
|
||||
tracker[contentKey] = node
|
||||
return node
|
||||
}
|
||||
|
||||
fun dispose(node: SETNode) = tracker.remove(node.contentKey, node)
|
||||
|
||||
infix fun connectTo(other: SETNodeGenerator) {
|
||||
opposites.add(other)
|
||||
other.opposites.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ModifierFactoryUnreferencedReceiver")
|
||||
object NoopSharedTransitionScope : SharedTransitionScope {
|
||||
override val isTransitionActive: Boolean
|
||||
get() = TODO("Not yet implemented")
|
||||
override val Placeable.PlacementScope.lookaheadScopeCoordinates: LayoutCoordinates
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
override fun OverlayClip(clipShape: Shape): SharedTransitionScope.OverlayClip {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberSharedContentState(key: Any): SharedTransitionScope.SharedContentState {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun Modifier.renderInSharedTransitionScopeOverlay(
|
||||
renderInOverlay: () -> Boolean,
|
||||
zIndexInOverlay: Float,
|
||||
clipInOverlayDuringTransition: (LayoutDirection, Density) -> Path?,
|
||||
): Modifier {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun Modifier.sharedBounds(
|
||||
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
enter: EnterTransition,
|
||||
exit: ExitTransition,
|
||||
boundsTransform: BoundsTransform,
|
||||
resizeMode: SharedTransitionScope.ResizeMode,
|
||||
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
|
||||
renderInOverlayDuringTransition: Boolean,
|
||||
zIndexInOverlay: Float,
|
||||
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
|
||||
): Modifier {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun Modifier.sharedElement(
|
||||
state: SharedTransitionScope.SharedContentState,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
boundsTransform: BoundsTransform,
|
||||
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
|
||||
renderInOverlayDuringTransition: Boolean,
|
||||
zIndexInOverlay: Float,
|
||||
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
|
||||
): Modifier {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun Modifier.sharedElementWithCallerManagedVisibility(
|
||||
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||
visible: Boolean,
|
||||
boundsTransform: BoundsTransform,
|
||||
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
|
||||
renderInOverlayDuringTransition: Boolean,
|
||||
zIndexInOverlay: Float,
|
||||
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
|
||||
): Modifier {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun Modifier.skipToLookaheadSize(): Modifier {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun LayoutCoordinates.toLookaheadCoordinates(): LayoutCoordinates {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
|
||||
val EmptyWindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
@@ -16,7 +16,7 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import io.ktor.utils.io.copyTo
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.util.Locale
|
||||
|
||||
@@ -5,15 +5,15 @@ import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.charsets.decode
|
||||
import io.ktor.utils.io.core.ByteReadPacket
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import kotlinx.io.Source
|
||||
|
||||
suspend fun HttpResponse.bodyAsUtf8Text(): String {
|
||||
val decoder = Charsets.UTF_8.newDecoder().apply {
|
||||
onMalformedInput(CodingErrorAction.REPLACE)
|
||||
onUnmappableCharacter(CodingErrorAction.REPLACE)
|
||||
}
|
||||
val input = body<ByteReadPacket>()
|
||||
val input = body<Source>()
|
||||
|
||||
return decoder.decode(input)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ suspend fun Context.requestPermission(key: String): Boolean {
|
||||
return awaitActivityResult(ActivityResultContracts.RequestPermission(), key)
|
||||
}
|
||||
|
||||
suspend fun Context.pickVisualMedia(type: VisualMediaType): Uri? = awaitActivityResult(ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest.Builder().setMediaType(type).build())
|
||||
suspend fun Context.pickVisualMedia(type: VisualMediaType): Uri? = awaitActivityResult(ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest(mediaType = type))
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
suspend fun Context.requestInstallPermission(): Boolean {
|
||||
|
||||
@@ -16,7 +16,9 @@ val Uri.displayPath: String?
|
||||
|
||||
val context = appCtx
|
||||
if (DocumentsContract.isDocumentUri(context, this)) {
|
||||
val (type, path) = DocumentsContract.getDocumentId(this).split(":", limit = 2)
|
||||
val (type, path) = DocumentsContract.getDocumentId(this).split(":", limit = 2).also {
|
||||
if (it.size < 2) return toString()
|
||||
}
|
||||
if (authority == "com.android.externalstorage.documents") {
|
||||
if (type == "primary") {
|
||||
return Environment.getExternalStorageDirectory().path + "/" + path
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.hippo.ehviewer.util
|
||||
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import androidx.core.os.BuildCompat
|
||||
|
||||
val isAtLeastN = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
|
||||
val isAtLeastO = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
@@ -13,5 +12,4 @@ val isAtLeastR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
val isAtLeastS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val isAtLeastT = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
val isAtLeastU = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
|
||||
val isAtLeastV = BuildCompat.isAtLeastV()
|
||||
val isAtLeastSExtension7 = isAtLeastR && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7
|
||||
@@ -26,7 +26,13 @@ class AndroidFileSystem(context: Context) : FileSystem() {
|
||||
}
|
||||
|
||||
override fun atomicMove(source: Path, target: Path) {
|
||||
TODO("Not yet implemented")
|
||||
if (source.isPhysicalFile()) {
|
||||
return physicalFileSystem.atomicMove(source, target)
|
||||
}
|
||||
|
||||
source.runCatching {
|
||||
DocumentsContract.renameDocument(contentResolver, toUri(), target.name)
|
||||
}.getOrNull() ?: throw IOException("Failed to move $source to $target")
|
||||
}
|
||||
|
||||
override fun canonicalize(path: Path): Path {
|
||||
@@ -47,9 +53,9 @@ class AndroidFileSystem(context: Context) : FileSystem() {
|
||||
}
|
||||
}
|
||||
|
||||
dir.parent?.let {
|
||||
DocumentsContract.createDocument(contentResolver, it.toUri(), Document.MIME_TYPE_DIR, dir.name)
|
||||
} ?: throw IOException("Failed to create directory: $dir")
|
||||
dir.parent?.runCatching {
|
||||
DocumentsContract.createDocument(contentResolver, toUri(), Document.MIME_TYPE_DIR, dir.name)
|
||||
}?.getOrNull() ?: throw IOException("Failed to create directory: $dir")
|
||||
}
|
||||
|
||||
override fun createSymlink(source: Path, target: Path) {
|
||||
@@ -69,7 +75,9 @@ class AndroidFileSystem(context: Context) : FileSystem() {
|
||||
uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getDocumentId(uri) + '/')
|
||||
}
|
||||
|
||||
val deleted = DocumentsContract.deleteDocument(contentResolver, uri)
|
||||
val deleted = runCatching {
|
||||
DocumentsContract.deleteDocument(contentResolver, uri)
|
||||
}.getOrDefault(false)
|
||||
if (!deleted) {
|
||||
throw IOException("Failed to delete $path")
|
||||
}
|
||||
@@ -168,25 +176,20 @@ class AndroidFileSystem(context: Context) : FileSystem() {
|
||||
}
|
||||
|
||||
fun openFileDescriptor(path: Path, mode: String): ParcelFileDescriptor {
|
||||
if (path.isPhysicalFile()) {
|
||||
return ParcelFileDescriptor.open(path.toFile(), ParcelFileDescriptor.parseMode(mode))
|
||||
}
|
||||
|
||||
if ('w' in mode && !exists(path)) {
|
||||
val parent = path.parent ?: throw IOException("Failed to open file: $path")
|
||||
createDirectories(parent)
|
||||
if (path.isPhysicalFile()) {
|
||||
physicalFileSystem.openReadWrite(path).close()
|
||||
} else {
|
||||
path.parent?.runCatching {
|
||||
val displayName = path.name
|
||||
val extension = displayName.substringAfterLast('.', "").ifEmpty { null }?.lowercase()
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "application/octet-stream"
|
||||
DocumentsContract.createDocument(contentResolver, parent.toUri(), mimeType, displayName)
|
||||
?: throw IOException("Failed to open file: $path")
|
||||
}
|
||||
DocumentsContract.createDocument(contentResolver, toUri(), mimeType, displayName)
|
||||
}?.getOrNull() ?: throw IOException("Failed to open file: $path")
|
||||
}
|
||||
|
||||
return if (path.isPhysicalFile()) {
|
||||
ParcelFileDescriptor.open(path.toFile(), ParcelFileDescriptor.parseMode(mode))
|
||||
} else {
|
||||
contentResolver.openFileDescriptor(path.toUri(), mode)
|
||||
} ?: throw IOException("Failed to open file: $path")
|
||||
return contentResolver.openFileDescriptor(path.toUri(), mode) ?: throw IOException("Failed to open file: $path")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ fun Path.delete() = SystemFileSystem.deleteRecursively(this)
|
||||
|
||||
fun Path.list() = SystemFileSystem.listOrNull(this).orEmpty()
|
||||
|
||||
fun Path.mkdirs() = SystemFileSystem.createDirectories(this)
|
||||
|
||||
fun Path.moveTo(target: Path) = SystemFileSystem.atomicMove(this, target)
|
||||
|
||||
fun Path.openFileDescriptor(mode: String) = SystemFileSystem.openFileDescriptor(this, mode)
|
||||
|
||||
fun Path.openInputStream(): FileInputStream =
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
open class Page(val index: Int) {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
var status = MutableStateFlow(State.QUEUE)
|
||||
|
||||
var errorMsg: String? = null
|
||||
|
||||
private val _progressFlow = MutableStateFlow(0)
|
||||
|
||||
val progressFlow = _progressFlow.asStateFlow()
|
||||
var progress: Int
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUE,
|
||||
LOAD_PAGE,
|
||||
DOWNLOAD_IMAGE,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -108,7 +108,7 @@ private fun DropdownIconButton(
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = selectedItem.icon,
|
||||
@@ -119,7 +119,7 @@ private fun DropdownIconButton(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.widthIn(min = minMenuWidth),
|
||||
matchAnchorWidth = false,
|
||||
matchTextFieldWidth = false,
|
||||
) {
|
||||
menuItems.forEach {
|
||||
DropdownMenuItem(
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -37,6 +38,7 @@ fun ReaderPageSheetMeta(
|
||||
copy: () -> Unit,
|
||||
save: () -> Unit,
|
||||
saveTo: () -> Unit,
|
||||
showAds: (() -> Unit)?,
|
||||
dismiss: () -> Unit,
|
||||
) {
|
||||
@Composable
|
||||
@@ -52,6 +54,7 @@ fun ReaderPageSheetMeta(
|
||||
modifier = Modifier.fillMaxSize() // Workaround for https://issuetracker.google.com/341594885
|
||||
.verticalScroll(rememberScrollState()).navigationBarsPadding(),
|
||||
) {
|
||||
showAds?.let { Item(icon = Icons.Default.Visibility, text = R.string.show_blocked_image, onClick = it) }
|
||||
Item(icon = Icons.Default.Refresh, text = R.string.refresh, onClick = retry)
|
||||
Item(icon = Icons.Default.Refresh, text = R.string.refresh_original, onClick = retryOrigin)
|
||||
Item(icon = Icons.Default.Share, text = R.string.action_share, onClick = share)
|
||||
|
||||
@@ -3,12 +3,16 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.collection.lruCache
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageStatus
|
||||
import com.hippo.ehviewer.gallery.reset
|
||||
import com.hippo.ehviewer.gallery.status
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
|
||||
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
|
||||
@@ -21,7 +25,7 @@ abstract class PageLoader {
|
||||
} else {
|
||||
(OSUtils.appMaxMemory / 3 * 2).toInt()
|
||||
},
|
||||
sizeOf = { _, v -> v.size.toInt() },
|
||||
sizeOf = { _, v -> v.allocationSize.toInt() },
|
||||
onEntryRemoved = { _, k, o, n ->
|
||||
if (o.isRecyclable) {
|
||||
n ?: notifyPageWait(k)
|
||||
@@ -35,7 +39,7 @@ abstract class PageLoader {
|
||||
|
||||
val pages by lazy {
|
||||
check(size > 0)
|
||||
(0 until size).map { ReaderPage(it) }
|
||||
(0 until size).map { Page(it) }
|
||||
}
|
||||
|
||||
private val prefetchPageCount = Settings.preloadImage
|
||||
@@ -58,6 +62,7 @@ abstract class PageLoader {
|
||||
|
||||
fun restart() {
|
||||
cache.evictAll()
|
||||
pages.forEach(Page::reset)
|
||||
}
|
||||
|
||||
abstract val size: Int
|
||||
@@ -79,7 +84,12 @@ abstract class PageLoader {
|
||||
} else {
|
||||
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
|
||||
}
|
||||
val pagesAbsent = prefetchRange.filter { pages[it].status.value == Page.State.QUEUE }
|
||||
val pagesAbsent = prefetchRange.filter {
|
||||
when (pages[it].status) {
|
||||
PageStatus.Queued, is PageStatus.Error -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val start = if (prefetchRange.step > 0) prefetchRange.first else prefetchRange.last
|
||||
val end = if (prefetchRange.step > 0) prefetchRange.last else prefetchRange.first
|
||||
prefetchPages(pagesAbsent, start - 5 to end + 5)
|
||||
@@ -104,32 +114,27 @@ abstract class PageLoader {
|
||||
|
||||
protected abstract fun onForceRequest(index: Int, orgImg: Boolean)
|
||||
|
||||
fun cancelRequest(index: Int) {
|
||||
onCancelRequest(index)
|
||||
}
|
||||
|
||||
protected abstract fun onCancelRequest(index: Int)
|
||||
|
||||
fun notifyPageWait(index: Int) {
|
||||
pages[index].progress = 0
|
||||
pages[index].status.value = Page.State.QUEUE
|
||||
pages[index].reset()
|
||||
}
|
||||
|
||||
fun notifyPagePercent(index: Int, percent: Float) {
|
||||
pages[index].progress = (percent * 100).toInt()
|
||||
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
|
||||
pages[index].statusFlow.update {
|
||||
when (it) {
|
||||
is PageStatus.Loading -> it.apply { progress.update { percent } }
|
||||
else -> PageStatus.Loading(MutableStateFlow(percent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyPageSucceed(index: Int, image: Image, replaceCache: Boolean = true) {
|
||||
if (replaceCache) {
|
||||
cache.put(index, image)
|
||||
}
|
||||
pages[index].image = image
|
||||
pages[index].status.value = Page.State.READY
|
||||
pages[index].statusFlow.update { if (image.hasQrCode) PageStatus.Blocked(image) else PageStatus.Ready(image) }
|
||||
}
|
||||
|
||||
fun notifyPageFailed(index: Int, error: String?) {
|
||||
pages[index].errorMsg = error
|
||||
pages[index].status.value = Page.State.ERROR
|
||||
pages[index].statusFlow.update { PageStatus.Error(error) }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
|
||||
class ReaderPage(index: Int) : Page(index) {
|
||||
var image: Image? = null
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import com.hippo.ehviewer.R
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
@@ -12,7 +13,6 @@ import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import eu.kanade.tachiyomi.util.lang.invert
|
||||
|
||||
abstract class ViewerNavigation {
|
||||
|
||||
sealed class NavigationRegion(@StringRes val nameRes: Int, val colorRes: Int) {
|
||||
data object MENU : NavigationRegion(R.string.action_menu, R.color.navigation_menu)
|
||||
data object PREV : NavigationRegion(R.string.nav_zone_prev, R.color.navigation_prev)
|
||||
@@ -21,37 +21,29 @@ abstract class ViewerNavigation {
|
||||
data object RIGHT : NavigationRegion(R.string.nav_zone_right, R.color.navigation_right)
|
||||
}
|
||||
|
||||
data class Region(
|
||||
val rectF: RectF,
|
||||
val type: NavigationRegion,
|
||||
) {
|
||||
data class Region(val rect: Rect, val type: NavigationRegion) {
|
||||
fun invert(invertMode: TappingInvertMode): Region {
|
||||
if (invertMode == TappingInvertMode.NONE) return this
|
||||
return this.copy(
|
||||
rectF = this.rectF.invert(invertMode),
|
||||
)
|
||||
return copy(rect = rect.invert(invertMode))
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val originalRegions: List<Region>
|
||||
|
||||
val regions get() = originalRegions.map { it.invert(invertMode) }
|
||||
|
||||
var invertMode = TappingInvertMode.NONE
|
||||
fun regions(invertMode: TappingInvertMode = TappingInvertMode.NONE) = originalRegions.map { it.invert(invertMode) }
|
||||
|
||||
companion object {
|
||||
fun fromPreference(value: Int, isVertical: Boolean) = when (value) {
|
||||
1 -> LNavigation()
|
||||
2 -> KindlishNavigation()
|
||||
3 -> EdgeNavigation()
|
||||
4 -> RightAndLeftNavigation()
|
||||
5 -> DisabledNavigation()
|
||||
else -> if (isVertical) LNavigation() else RightAndLeftNavigation()
|
||||
1 -> LNavigation
|
||||
2 -> KindlishNavigation
|
||||
3 -> EdgeNavigation
|
||||
4 -> RightAndLeftNavigation
|
||||
5 -> DisabledNavigation
|
||||
else -> if (isVertical) LNavigation else RightAndLeftNavigation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias NavigationRegions = List<ViewerNavigation.Region>
|
||||
|
||||
fun NavigationRegions.getAction(x: Float, y: Float) =
|
||||
find { it.rectF.contains(x, y) }?.type ?: ViewerNavigation.NavigationRegion.MENU
|
||||
fun NavigationRegions.getAction(offset: Offset) = find { offset in it.rect }?.type ?: ViewerNavigation.NavigationRegion.MENU
|
||||
+2
-3
@@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | M | M | M | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class DisabledNavigation : ViewerNavigation() {
|
||||
|
||||
override val originalRegions: List<Region> = emptyList()
|
||||
object DisabledNavigation : ViewerNavigation() {
|
||||
override val originalRegions = emptyList<Region>()
|
||||
}
|
||||
+6
-7
@@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -13,19 +13,18 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | P | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class EdgeNavigation : ViewerNavigation() {
|
||||
|
||||
override val originalRegions: List<Region> = listOf(
|
||||
object EdgeNavigation : ViewerNavigation() {
|
||||
override val originalRegions = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
rect = Rect(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0.33f, 0.66f, 0.66f, 1f),
|
||||
rect = Rect(0.33f, 0.66f, 0.66f, 1f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0.66f, 0f, 1f, 1f),
|
||||
rect = Rect(0.66f, 0f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -13,15 +13,14 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | P | N | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class KindlishNavigation : ViewerNavigation() {
|
||||
|
||||
override val originalRegions: List<Region> = listOf(
|
||||
object KindlishNavigation : ViewerNavigation() {
|
||||
override val originalRegions = listOf(
|
||||
Region(
|
||||
rectF = RectF(0.33f, 0.33f, 1f, 1f),
|
||||
rect = Rect(0.33f, 0.33f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0f, 0.33f, 0.33f, 1f),
|
||||
rect = Rect(0f, 0.33f, 0.33f, 1f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -13,23 +13,22 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | N | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
open class LNavigation : ViewerNavigation() {
|
||||
|
||||
override val originalRegions: List<Region> = listOf(
|
||||
object LNavigation : ViewerNavigation() {
|
||||
override val originalRegions = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
|
||||
rect = Rect(0f, 0.33f, 0.33f, 0.66f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0f, 0f, 1f, 0.33f),
|
||||
rect = Rect(0f, 0f, 1f, 0.33f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0.66f, 0.33f, 1f, 0.66f),
|
||||
rect = Rect(0.66f, 0.33f, 1f, 0.66f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0f, 0.66f, 1f, 1f),
|
||||
rect = Rect(0f, 0.66f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -13,15 +13,14 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | M | P | N: Move Left
|
||||
* +---+---+---+
|
||||
*/
|
||||
class RightAndLeftNavigation : ViewerNavigation() {
|
||||
|
||||
override val originalRegions: List<Region> = listOf(
|
||||
object RightAndLeftNavigation : ViewerNavigation() {
|
||||
override val originalRegions = listOf(
|
||||
Region(
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
rect = Rect(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.LEFT,
|
||||
),
|
||||
Region(
|
||||
rectF = RectF(0.66f, 0f, 1f, 1f),
|
||||
rect = Rect(0.66f, 0f, 1f, 1f),
|
||||
type = NavigationRegion.RIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
|
||||
fun Rect.invert(invertMode: TappingInvertMode): Rect {
|
||||
val horizontal = invertMode.shouldInvertHorizontal
|
||||
val vertical = invertMode.shouldInvertVertical
|
||||
return when {
|
||||
horizontal && vertical -> Rect(1f - right, 1f - bottom, 1f - left, 1f - top)
|
||||
vertical -> Rect(left, 1f - bottom, right, 1f - top)
|
||||
horizontal -> Rect(1f - right, top, 1f - left, bottom)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
|
||||
fun RectF.invert(invertMode: TappingInvertMode): RectF {
|
||||
val horizontal = invertMode.shouldInvertHorizontal
|
||||
val vertical = invertMode.shouldInvertVertical
|
||||
return when {
|
||||
horizontal && vertical -> RectF(1f - this.right, 1f - this.bottom, 1f - this.left, 1f - this.top)
|
||||
vertical -> RectF(this.left, 1f - this.bottom, this.right, 1f - this.top)
|
||||
horizontal -> RectF(1f - this.right, this.top, 1f - this.left, this.bottom)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,8 @@
|
||||
<string name="settings_read_volume_page_fast">快</string>
|
||||
<string name="settings_read_volume_page_slow">慢</string>
|
||||
<string name="settings_read_reverse_volume">翻转音量键</string>
|
||||
<string name="settings_block_extraneous_ads">[实验性] 屏蔽外部广告</string>
|
||||
<string name="settings_ads_placeholder">[可选] 选择替换外部广告的图片</string>
|
||||
<string name="settings_download">下载</string>
|
||||
<string name="settings_download_download_location">下载路径</string>
|
||||
<string name="settings_download_cant_get_download_location">无法获取下载路径</string>
|
||||
@@ -423,6 +425,8 @@
|
||||
<string name="insufficient_funds">余额不足</string>
|
||||
<string name="image_limits">图片配额</string>
|
||||
<string name="image_limits_summary">已用:%1$d / %2$d</string>
|
||||
<string name="image_limits_normal">无限制</string>
|
||||
<string name="image_limits_restricted">图片分辨率限制为 1280x</string>
|
||||
<string name="reset_cost">重置花费:%d GP</string>
|
||||
<string name="reset_limits_succeed">成功重置图片配额</string>
|
||||
<string name="reset">重置</string>
|
||||
@@ -468,6 +472,7 @@
|
||||
<string name="action_add_tag">添加标签</string>
|
||||
<string name="action_add_tag_tip">添加新标签,用逗号「,」分割</string>
|
||||
<string name="comment_user_uploader">%s (上传者)</string>
|
||||
<string name="no_network">无网络</string>
|
||||
<string name="settings_eh_metered_network_warning">流量计费网络警告</string>
|
||||
<string name="metered_network_warning">正在使用流量计费网络</string>
|
||||
<string name="read_from">从第 %d 页阅读</string>
|
||||
@@ -493,6 +498,8 @@
|
||||
<string name="preload_thumb_aggressively">激进地预载缩略图</string>
|
||||
<string name="animate_items">列表项目动画</string>
|
||||
<string name="animate_items_summary">如遇崩溃/掉帧请尝试禁用此项</string>
|
||||
<string name="desktop_site">桌面版网站</string>
|
||||
<string name="desktop_site_summary">更高的默认图片分辨率</string>
|
||||
<string name="auto_updates">自动检查更新</string>
|
||||
<string name="update_frequency_never">从不</string>
|
||||
<string name="update_frequency_daily">每天</string>
|
||||
@@ -602,6 +609,8 @@
|
||||
<string name="dark_theme_off">总是关闭</string>
|
||||
<string name="dark_theme_on">总是开启</string>
|
||||
<string name="pref_crop_borders">裁剪边缘</string>
|
||||
<string name="blocked_image">已屏蔽的图片</string>
|
||||
<string name="show_blocked_image">显示已屏蔽的图片</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
|
||||
@@ -337,6 +337,8 @@
|
||||
<string name="settings_read_volume_page_fast">Fast</string>
|
||||
<string name="settings_read_volume_page_slow">Slow</string>
|
||||
<string name="settings_read_reverse_volume">Reverse volume key</string>
|
||||
<string name="settings_block_extraneous_ads">[Experimental] Block extraneous ads</string>
|
||||
<string name="settings_ads_placeholder">[Optional] Pick placeholder to replace ads</string>
|
||||
<string name="settings_download">Download</string>
|
||||
<string name="settings_download_download_location">Download location</string>
|
||||
<string name="settings_download_cant_get_download_location">Can\'t get download location</string>
|
||||
@@ -444,6 +446,8 @@
|
||||
<string name="insufficient_funds">Insufficient Funds</string>
|
||||
<string name="image_limits">Image Limits</string>
|
||||
<string name="image_limits_summary">Used: %1$d / %2$d</string>
|
||||
<string name="image_limits_normal">No restrictions</string>
|
||||
<string name="image_limits_restricted">Image resolution restricted to 1280x</string>
|
||||
<string name="reset_cost">Reset Cost: %d GP</string>
|
||||
<string name="reset_limits_succeed">Image limit was successfully reset</string>
|
||||
<string name="reset">Reset</string>
|
||||
@@ -485,6 +489,7 @@
|
||||
<string name="action_add_tag">Add tag</string>
|
||||
<string name="action_add_tag_tip">Enter new tags, separated with comma</string>
|
||||
<string name="comment_user_uploader">%s (Uploader)</string>
|
||||
<string name="no_network">No network</string>
|
||||
<string name="settings_eh_metered_network_warning">Metered network warning</string>
|
||||
<string name="metered_network_warning">Connected to metered networks</string>
|
||||
<string name="read_from">Read page %d</string>
|
||||
@@ -512,7 +517,8 @@
|
||||
<string name="preload_thumb_aggressively">Preload thumbs aggressively</string>
|
||||
<string name="animate_items">List item animations</string>
|
||||
<string name="animate_items_summary">Try disabling this if you are facing crashes / frame drops</string>
|
||||
<string name="user_agent">User Agent</string>
|
||||
<string name="desktop_site">Desktop site</string>
|
||||
<string name="desktop_site_summary">Higher default image resolution</string>
|
||||
<string name="auto_updates">Automatically check for updates</string>
|
||||
<string name="update_frequency_never">Never</string>
|
||||
<string name="update_frequency_daily">Daily</string>
|
||||
@@ -621,6 +627,8 @@
|
||||
<string name="dark_theme_follow_system">Follow system</string>
|
||||
<string name="dark_theme_off">Always off</string>
|
||||
<string name="dark_theme_on">Always on</string>
|
||||
<string name="blocked_image">Blocked image</string>
|
||||
<string name="show_blocked_image">Show blocked image</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
|
||||
@@ -16,7 +16,4 @@
|
||||
~ If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">ehtracker.org</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
</network-security-config>
|
||||
Generated
+748
-23
@@ -2,6 +2,21 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.1"
|
||||
@@ -20,16 +35,85 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.86"
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bit_reverse"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99528ca30abb9495c7e106bf7c3177b257c62040fc0f2909fe470b0f43097296"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
|
||||
checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
@@ -43,6 +127,51 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz-build"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codepage-437"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535"
|
||||
dependencies = [
|
||||
"csv",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -53,22 +182,116 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
|
||||
dependencies = [
|
||||
"csv-core",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ehviewer_rust"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
"image",
|
||||
"jni",
|
||||
"jni_fn",
|
||||
"log",
|
||||
"ndk",
|
||||
"quick-xml",
|
||||
"regex-lite",
|
||||
"rxing",
|
||||
"serde",
|
||||
"serde_cbor",
|
||||
"tl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding"
|
||||
version = "0.2.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
|
||||
dependencies = [
|
||||
"encoding-index-japanese",
|
||||
"encoding-index-korean",
|
||||
"encoding-index-simpchinese",
|
||||
"encoding-index-singlebyte",
|
||||
"encoding-index-tradchinese",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-japanese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-korean"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-simpchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-singlebyte"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-tradchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_index_tests"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.2"
|
||||
@@ -78,12 +301,91 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -117,6 +419,27 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.158"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.22"
|
||||
@@ -129,6 +452,195 @@ version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.86"
|
||||
@@ -140,28 +652,117 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.1"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
version = "1.0.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
||||
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
|
||||
|
||||
[[package]]
|
||||
name = "rxing"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "786ea2f74b327842bab8c9e611fa3e40f1549d7931fb6e63d1fc2f84e06c2323"
|
||||
dependencies = [
|
||||
"bit_reverse",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"codepage-437",
|
||||
"encoding",
|
||||
"fancy-regex",
|
||||
"multimap",
|
||||
"num",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"rxing-one-d-proc-derive",
|
||||
"thiserror",
|
||||
"unicode-segmentation",
|
||||
"uriparse",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rxing-one-d-proc-derive"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59c92a4da773eed06d54210368684b4325bc70a0f8b0cc4d50a106bf71b954f2"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -173,9 +774,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.208"
|
||||
version = "1.0.210"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2"
|
||||
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@@ -192,9 +793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.208"
|
||||
version = "1.0.210"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf"
|
||||
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -202,10 +803,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.75"
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -214,18 +827,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.63"
|
||||
version = "1.0.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
|
||||
checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.63"
|
||||
version = "1.0.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
|
||||
checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -239,10 +852,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
name = "toml_datetime"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "uriparse"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
@@ -254,6 +906,61 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.9"
|
||||
@@ -263,6 +970,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
@@ -401,3 +1117,12 @@ name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -6,8 +6,11 @@ edition = "2021"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
api-level-26 = ["ndk/api-level-26"]
|
||||
|
||||
[dependencies]
|
||||
regex-lite = { version = "0.1" }
|
||||
regex-lite = "0.1"
|
||||
jni = "0.21"
|
||||
jni_fn = "0.1"
|
||||
quick-xml = { default-features = false, version = "0.36" }
|
||||
@@ -16,7 +19,10 @@ log = "0.4"
|
||||
android_logger = { default-features = false, version = "0.14" }
|
||||
serde_cbor = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
anyhow = { version = "1.0" }
|
||||
anyhow = "1.0"
|
||||
image = { default-features = false, version = "0.25" }
|
||||
rxing = { default-features = false, version = "0.6" }
|
||||
ndk = { version = "0.9", features = ["bitmap"] }
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mod border;
|
||||
mod copy_ahb;
|
||||
mod qr_code;
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::{jni_throwing, with_bitmap_content};
|
||||
use anyhow::Context;
|
||||
use image::{ImageBuffer, Luma, Pixel, Rgba};
|
||||
use jni::objects::JClass;
|
||||
use jni::sys::{jintArray, jobject};
|
||||
use jni::JNIEnv;
|
||||
use jni_fn::jni_fn;
|
||||
|
||||
/** A line will be considered as having content if 0.25% of it is filled. */
|
||||
static FILLED_RATIO_LIMIT: f32 = 0.0025;
|
||||
|
||||
/** When the threshold is closer to 1, less content will be cropped. **/
|
||||
static THRESHOLD: f32 = 0.75;
|
||||
|
||||
static THRESHOLD_FOR_BLACK: u8 = (255.0 * THRESHOLD) as u8;
|
||||
static THRESHOLD_FOR_WHITE: u8 = (255.0 - 255.0 * THRESHOLD) as u8;
|
||||
|
||||
fn is_white(pixel: &Luma<u8>) -> bool {
|
||||
pixel.0[0] > THRESHOLD_FOR_WHITE
|
||||
}
|
||||
|
||||
fn is_black(pixel: &Luma<u8>) -> bool {
|
||||
pixel.0[0] < THRESHOLD_FOR_BLACK
|
||||
}
|
||||
|
||||
fn line_not_filled_by<'pixel>(
|
||||
line: impl Iterator<Item = &'pixel Rgba<u8>>,
|
||||
white: bool,
|
||||
limit: i32,
|
||||
) -> bool {
|
||||
let f = if white { is_black } else { is_white };
|
||||
line.step_by(2).filter(|p| f(&p.to_luma())).count() as i32 > limit
|
||||
}
|
||||
|
||||
fn try_count_lines<'pixel>(
|
||||
iter: impl Iterator<Item = impl Iterator<Item = &'pixel Rgba<u8>>>,
|
||||
white: bool,
|
||||
limit: i32,
|
||||
) -> i32 {
|
||||
let mut count = 1;
|
||||
for line in iter {
|
||||
if line_not_filled_by(line, white, limit) {
|
||||
break;
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn detect_border_lines<'pixel>(
|
||||
mut iter: impl Iterator<Item = impl Iterator<Item = &'pixel Rgba<u8>>>,
|
||||
line_len: u32,
|
||||
) -> i32 {
|
||||
let first_row = iter.next().expect("Image is empty!");
|
||||
let filled_limit = (line_len as f32 * FILLED_RATIO_LIMIT / 2.0).round() as i32;
|
||||
let (mut black, mut white) = (0, 0);
|
||||
for pixel in first_row.step_by(2) {
|
||||
let luma = &pixel.to_luma();
|
||||
if is_black(luma) {
|
||||
black += 1;
|
||||
} else if is_white(luma) {
|
||||
white += 1;
|
||||
}
|
||||
}
|
||||
match (black > filled_limit, white > filled_limit) {
|
||||
// Black is dominant color.
|
||||
(true, false) => try_count_lines(iter, false, filled_limit),
|
||||
// White is dominant color.
|
||||
(false, true) => try_count_lines(iter, true, filled_limit),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_border(image: &ImageBuffer<Rgba<u8>, &[u8]>) -> Option<[i32; 4]> {
|
||||
let (w, h) = image.dimensions();
|
||||
let top = detect_border_lines(image.rows(), w);
|
||||
let bottom = detect_border_lines(image.rows().rev(), w);
|
||||
let iter = (0..w).map(|row| (0..h).map(move |col| image.get_pixel(row, col)));
|
||||
let left = detect_border_lines(iter.clone(), h);
|
||||
let right = detect_border_lines(iter.rev(), h);
|
||||
Some([left, top, w as i32 - right, h as i32 - bottom])
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(non_snake_case)]
|
||||
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
|
||||
pub fn detectBorder(mut env: JNIEnv, _class: JClass, object: jobject) -> jintArray {
|
||||
jni_throwing(&mut env, |env| {
|
||||
let slice = with_bitmap_content(env, object, |img| Ok(detect_border(&img)))?;
|
||||
let array = env.new_int_array(4)?;
|
||||
env.set_int_array_region(&array, 0, &slice.context("Image too small!")?)?;
|
||||
Ok(array.into_raw())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#![cfg(feature = "api-level-26")]
|
||||
|
||||
use crate::{jni_throwing, with_bitmap_content};
|
||||
use anyhow::Context;
|
||||
use image::{GenericImage, GenericImageView, ImageBuffer};
|
||||
use jni::objects::JClass;
|
||||
use jni::sys::{jint, jobject};
|
||||
use jni::JNIEnv;
|
||||
use jni_fn::jni_fn;
|
||||
use ndk::hardware_buffer::{HardwareBuffer, HardwareBufferUsage};
|
||||
use std::{ops::Deref, ptr::slice_from_raw_parts_mut};
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(non_snake_case)]
|
||||
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
|
||||
pub fn copyBitmapToAHB(mut env: JNIEnv, _: JClass, bm: jobject, ahb: jobject, x: jint, y: jint) {
|
||||
let ahb = unsafe { HardwareBuffer::from_jni(env.get_raw(), ahb) };
|
||||
jni_throwing(&mut env, |env| {
|
||||
with_bitmap_content(env, bm, |src| {
|
||||
let desc = ahb.describe();
|
||||
let (w, h, stride) = (desc.width, desc.height, desc.stride);
|
||||
let view = src.view(x as u32, y as u32, w, h);
|
||||
let ptr = ahb.lock(HardwareBufferUsage::CPU_WRITE_RARELY, None, None)? as *mut u8;
|
||||
let s = unsafe { &mut *slice_from_raw_parts_mut(ptr, (stride * h * 4) as usize) };
|
||||
let dst = ImageBuffer::from_raw(stride, h, s).context("Illegal AHB format!");
|
||||
let result = dst.map(|mut dst| dst.copy_from(view.deref(), 0, 0));
|
||||
ahb.unlock()?;
|
||||
result??;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use crate::{jni_throwing, with_bitmap_content};
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use jni::objects::JClass;
|
||||
use jni::sys::{jboolean, jobject};
|
||||
use jni::JNIEnv;
|
||||
use jni_fn::jni_fn;
|
||||
use rxing::common::HybridBinarizer;
|
||||
use rxing::qrcode::detector::FinderPatternFinder;
|
||||
use rxing::{BinaryBitmap, DecodingHintDictionary, Point, RGBLuminanceSource};
|
||||
use std::f32::consts::FRAC_1_SQRT_2;
|
||||
use std::ptr::slice_from_raw_parts;
|
||||
|
||||
fn detect_image_ad(image: ImageBuffer<Rgba<u8>, &[u8]>) -> bool {
|
||||
let (width, height) = (image.width() as usize, image.height() as usize);
|
||||
let buffer = image.into_raw();
|
||||
let pixels = unsafe { &*slice_from_raw_parts(buffer.as_ptr() as *const u32, buffer.len()) };
|
||||
let source = RGBLuminanceSource::new_with_width_height_pixels(width, height, pixels);
|
||||
let image = BinaryBitmap::new(HybridBinarizer::new(source));
|
||||
match FinderPatternFinder::new(image.get_black_matrix()).find(&DecodingHintDictionary::new()) {
|
||||
Ok(info) => {
|
||||
// Check if the region is squarish
|
||||
let top_left = Point::from(info.getTopLeft());
|
||||
let top_right = Point::from(info.getTopRight());
|
||||
let bottom_left = Point::from(info.getBottomLeft());
|
||||
let length = top_right.distance(top_left);
|
||||
let epsilon = length * 0.05;
|
||||
(bottom_left.distance(top_left) - length).abs() < epsilon
|
||||
&& (bottom_left.distance(top_right) * FRAC_1_SQRT_2 - length).abs() < epsilon
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(non_snake_case)]
|
||||
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
|
||||
pub fn hasQrCode(mut env: JNIEnv, _class: JClass, object: jobject) -> jboolean {
|
||||
jni_throwing(&mut env, |env| {
|
||||
with_bitmap_content(env, object, |image| Ok(detect_image_ad(image) as jboolean))
|
||||
})
|
||||
}
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user