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 | ||
|
|
6191ad74d3 | ||
|
|
5aae8f5649 | ||
|
|
6886465afb | ||
|
|
5a46fc4573 | ||
|
|
a08b8e96e5 | ||
|
|
5aeded7a24 | ||
|
|
1883b82159 | ||
|
|
302b57d4ac | ||
|
|
c6b793923e | ||
|
|
058c280fd7 | ||
|
|
0fa42d731b | ||
|
|
9b54b57934 | ||
|
|
d3ab3ba61f | ||
|
|
921f570151 | ||
|
|
03ab435401 | ||
|
|
b73704ede1 | ||
|
|
97b2181ff7 | ||
|
|
ef5e3f7c51 | ||
|
|
8a29d46b38 | ||
|
|
75d8ff4b64 | ||
|
|
33258ea554 | ||
|
|
a723b4ecd3 | ||
|
|
78a237fb0e | ||
|
|
8fe60c0163 | ||
|
|
ffe47b2ff2 | ||
|
|
e05fffb41c | ||
|
|
1fb5de2527 | ||
|
|
46790a1fbe | ||
|
|
af1316f06b | ||
|
|
24564cac8d | ||
|
|
c4868f137b | ||
|
|
3ccfa0dd5b | ||
|
|
fded0fd08d | ||
|
|
b1afe9bcd1 | ||
|
|
22b5a175f4 | ||
|
|
ee9e0df12e | ||
|
|
dd525baf88 | ||
|
|
9180603ead | ||
|
|
66fe19369e | ||
|
|
b9db025f57 | ||
|
|
44f6eca3aa | ||
|
|
1ff1caf421 | ||
|
|
ec1355c0e7 | ||
|
|
d23a453f71 | ||
|
|
94b138f254 | ||
|
|
c281daa4ea | ||
|
|
4f9eba8c63 | ||
|
|
a2547f1e8d | ||
|
|
abcef9a86e | ||
|
|
7d6a9b10e4 | ||
|
|
9322b80790 | ||
|
|
e12897bd13 | ||
|
|
28279cc671 | ||
|
|
2531fa289b | ||
|
|
9673163e8f | ||
|
|
3f7091f880 | ||
|
|
5df75fd534 | ||
|
|
62061e54ee | ||
|
|
436c07fb6b | ||
|
|
a16f873f7d | ||
|
|
98fdcf0588 | ||
|
|
539ad1af30 | ||
|
|
4370606977 | ||
|
|
1ecdc07655 | ||
|
|
01a1113a07 | ||
|
|
fd6c2977b0 | ||
|
|
02e36e7021 | ||
|
|
7f8e4aa003 | ||
|
|
8ce16bc918 | ||
|
|
fbe35a0fa0 | ||
|
|
931f1ec514 | ||
|
|
7c4a59e9a4 | ||
|
|
c1c6f25763 | ||
|
|
e7f515c70f | ||
|
|
64fecbfa52 | ||
|
|
c6c181a9fa | ||
|
|
53a1bd9959 | ||
|
|
7b1f94cf5a | ||
|
|
4b8ecbf6a7 | ||
|
|
0e0b0519e7 | ||
|
|
7fb3b7c785 | ||
|
|
e18ebb1feb | ||
|
|
f76dcfc97b | ||
|
|
c0c630bd9f | ||
|
|
04df7da5dc | ||
|
|
d156638a79 | ||
|
|
c78fe0b72c | ||
|
|
799c37ea71 | ||
|
|
9559ca571b | ||
|
|
2f5c1e11c6 | ||
|
|
734c73fa4d | ||
|
|
c09173d62c | ||
|
|
533f50ed08 | ||
|
|
b4422fe910 | ||
|
|
22e58a034f | ||
|
|
784856d341 | ||
|
|
f82832ca36 | ||
|
|
6a70aa792e | ||
|
|
a0016dbf85 | ||
|
|
b982705b2f | ||
|
|
4c36398bed | ||
|
|
7e1b3abcf6 | ||
|
|
97bb65aa35 | ||
|
|
98c1a377ca | ||
|
|
2d2ce3281e | ||
|
|
a28d3ba840 | ||
|
|
ab2e4a8e2d | ||
|
|
67ddba87b5 | ||
|
|
8164847fcb |
No files matched your search
@@ -1,82 +0,0 @@
|
||||
name: Baseline profile generation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 0 * * 0'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'benchmark/**'
|
||||
- '.github/workflows/baseline-profile.yml'
|
||||
|
||||
jobs:
|
||||
baseline-profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Free disk space
|
||||
run: rm -rf ${ANDROID_SDK_ROOT}/ndk
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Update baseline profile
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config push.autoSetupRemote true
|
||||
git switch -c chore/baseline-profile
|
||||
if git commit -am "Update baseline profile"; then
|
||||
git push
|
||||
gh pr create -f
|
||||
fi
|
||||
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: reports
|
||||
path: benchmark/build/reports
|
||||
@@ -35,16 +35,13 @@ 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@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
@@ -63,9 +60,6 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
@@ -77,10 +71,9 @@ jobs:
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
gradle-home-cache-cleanup: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
@@ -90,8 +83,6 @@ jobs:
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
@@ -136,9 +127,6 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
@@ -150,10 +138,9 @@ jobs:
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
gradle-home-cache-cleanup: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
@@ -163,8 +150,6 @@ jobs:
|
||||
restore-keys: cmake-marshmallow-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleMarshmallowRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
|
||||
@@ -18,8 +18,16 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
- name: Free disk space
|
||||
run: |
|
||||
shopt -s extglob
|
||||
rm -rf ${ANDROID_SDK_ROOT}/ndk/!(27*)
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
@@ -36,12 +44,17 @@ jobs:
|
||||
key: cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
restore-keys: cmake-
|
||||
|
||||
- name: Gradle Build
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
arguments: assembleRelease
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: Generate Baseline Profile
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
|
||||
+71
-1
@@ -1,6 +1,76 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.11.6] - 2024-06-24
|
||||
## [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
|
||||
|
||||
### 重大变更
|
||||
|
||||
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
|
||||
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
|
||||
|
||||
### 改进
|
||||
|
||||
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
|
||||
* 关闭打开失败的压缩包时崩溃
|
||||
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
|
||||
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
|
||||
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
|
||||
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
|
||||
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
|
||||
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
|
||||
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
|
||||
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
|
||||
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
|
||||
* 图片未加载完成时无法通过点击翻页
|
||||
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
|
||||
* 缩略图分辨率设置不起作用
|
||||
|
||||
|
||||
## [1.11.7] - 2024-07-10
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 无法下载标题包含 # 或 % 的画廊
|
||||
* 下载页数较多的画廊时应用无响应
|
||||
* 退出搜索栏时文本闪烁
|
||||
|
||||
|
||||
## [1.11.6] - 2024-07-09 [YANKED]
|
||||
|
||||
### 改进
|
||||
|
||||
|
||||
@@ -94,8 +94,6 @@ Here is the libraries
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
# License
|
||||
|
||||
Copyright 2014-2019 Hippo Seven
|
||||
|
||||
+28
-39
@@ -1,6 +1,6 @@
|
||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
|
||||
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
|
||||
|
||||
val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
@@ -21,10 +21,27 @@ plugins {
|
||||
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
|
||||
android {
|
||||
compileSdk = if (isRelease) 35 else 34
|
||||
compileSdk = 35
|
||||
buildToolsVersion = "35.0.0"
|
||||
ndkVersion = "27.0.11902837-rc1"
|
||||
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 {
|
||||
@@ -64,38 +81,18 @@ android {
|
||||
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
|
||||
.readText().substringBefore('.')
|
||||
|
||||
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
|
||||
?: System.getenv("GITHUB_TOKEN").orEmpty()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 180057
|
||||
versionName = "1.11.6"
|
||||
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)
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
|
||||
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
|
||||
ndk {
|
||||
if (isRelease) {
|
||||
abiFilters.addAll(supportedAbis)
|
||||
@@ -153,9 +150,6 @@ android {
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
create("benchmarkRelease") {
|
||||
initWith(buildTypes.getByName("release"))
|
||||
@@ -169,15 +163,13 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
enableNonSkippingGroupOptimization = true
|
||||
enableStrongSkippingMode = true
|
||||
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
@@ -223,15 +215,11 @@ dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview)
|
||||
implementation(libs.material.motion.core)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
@@ -246,9 +234,7 @@ dependencies {
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.accompanist.drawable.painter)
|
||||
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
// implementation(libs.reorderable)
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
@@ -287,6 +273,8 @@ 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",
|
||||
@@ -295,6 +283,7 @@ kotlin {
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
|
||||
"-opt-in=androidx.paging.ExperimentalPagingApi",
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
|
||||
Vendored
-5
@@ -11,11 +11,6 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/347963245
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
|
||||
@@ -70,26 +70,6 @@
|
||||
<data android:host="exhentai.org" />
|
||||
<data android:host="e-hentai.org" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/keyword_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/image_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
|
||||
android:configChanges="screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -123,6 +103,20 @@
|
||||
<data android:mimeType="application/x-lzma-compressed-tar" />
|
||||
<data android:mimeType="application/x-xz-compressed-tar" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/keyword_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/image_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SecurityActivity"
|
||||
|
||||
@@ -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)
|
||||
+38
-32
@@ -52,7 +52,7 @@ pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static archive_ctx **ctx_pool = NULL;
|
||||
#define CTX_POOL_SIZE 20
|
||||
|
||||
static void *mempool = NULL;
|
||||
static void *mempool = MAP_FAILED;
|
||||
static size_t *mempoolofs = NULL;
|
||||
static int page_size = 0;
|
||||
|
||||
@@ -62,11 +62,11 @@ static int page_size = 0;
|
||||
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
|
||||
|
||||
#define PROT_RW (PROT_WRITE | PROT_READ)
|
||||
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
|
||||
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE)
|
||||
|
||||
static bool need_encrypt = false;
|
||||
static char *passwd = NULL;
|
||||
static void *archiveAddr = NULL;
|
||||
static void *archiveAddr = MAP_FAILED;
|
||||
static size_t archiveSize = 0;
|
||||
static entry *entries = NULL;
|
||||
static size_t entryCount = 0;
|
||||
@@ -117,8 +117,8 @@ static inline int compare_entries(const void *a, const void *b) {
|
||||
return strnatcmp(fa, fb);
|
||||
}
|
||||
|
||||
static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
long count = 0;
|
||||
static int archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
int count = 0;
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
|
||||
const char *name = archive_entry_pathname2(ctx->entry);
|
||||
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
|
||||
@@ -165,8 +165,8 @@ static void mempool_prefault_pages(void *addr, size_t size) {
|
||||
}
|
||||
}
|
||||
|
||||
static long archive_list_all_entries(archive_ctx *ctx) {
|
||||
long count = 0;
|
||||
static int archive_list_all_entries(archive_ctx *ctx) {
|
||||
int count = 0;
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
|
||||
if (archive_entry_is_playable(ctx->entry))
|
||||
count++;
|
||||
@@ -296,11 +296,12 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
LOGE("Allocate archive ctx pool failed:ENOMEM");
|
||||
return 0;
|
||||
}
|
||||
long r = archive_alloc_ctx(&ctx);
|
||||
int r = archive_alloc_ctx(&ctx);
|
||||
if (r) {
|
||||
if (r == -ENOMEM)
|
||||
LOGE("%s", "Mem alloc failed");
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
return 0;
|
||||
} else {
|
||||
entryCount = archive_list_all_entries(ctx);
|
||||
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
|
||||
@@ -347,10 +348,6 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
r = archive_map_entries_index(ctx, sort_entries);
|
||||
if (!archive_prealloc_mempool()) {
|
||||
r = 0;
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
}
|
||||
free(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,20 +400,32 @@ JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
if (!ctx_pool) return;
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
archive_release_ctx(ctx_pool[i]);
|
||||
if (ctx_pool) {
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
archive_release_ctx(ctx_pool[i]);
|
||||
free(ctx_pool);
|
||||
ctx_pool = NULL;
|
||||
}
|
||||
free(passwd);
|
||||
free(ctx_pool);
|
||||
passwd = NULL;
|
||||
need_encrypt = false;
|
||||
munmap(archiveAddr, archiveSize);
|
||||
munmap(mempool, MEMPOOL_SIZE);
|
||||
if (archiveAddr != MAP_FAILED) {
|
||||
munmap(archiveAddr, archiveSize);
|
||||
archiveAddr = MAP_FAILED;
|
||||
}
|
||||
if (mempool != MAP_FAILED) {
|
||||
munmap(mempool, MEMPOOL_SIZE);
|
||||
mempool = MAP_FAILED;
|
||||
}
|
||||
free(mempoolofs);
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
mempoolofs = NULL;
|
||||
if (entries) {
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
}
|
||||
free(entries);
|
||||
entries = NULL;
|
||||
}
|
||||
free(entries);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
@@ -433,12 +442,9 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
archive_ctx *ctx;
|
||||
jboolean ret = true;
|
||||
int len = (*env)->GetStringUTFLength(env, str);
|
||||
passwd = realloc(passwd, len * sizeof(char));
|
||||
if (!passwd) {
|
||||
LOGE("Allocate passwd buffer failed");
|
||||
return false;
|
||||
}
|
||||
passwd = realloc(passwd, len + 1);
|
||||
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
|
||||
passwd[len] = 0;
|
||||
archive_alloc_ctx(&ctx);
|
||||
void *tmpBuf = alloca(4096);
|
||||
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
|
||||
@@ -487,7 +493,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz
|
||||
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
|
||||
EH_UNUSED(clazz);
|
||||
struct archive *arc = archive_write_new();
|
||||
@@ -509,15 +515,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
|
||||
archive_entry_copy_stat(entry, &st);
|
||||
archive_entry_set_perm(entry, 0644);
|
||||
archive_write_header(arc, entry);
|
||||
int len = read(fd, buff, sizeof(buff));
|
||||
while (len > 0) {
|
||||
archive_write_data(arc, buff, len);
|
||||
int len;
|
||||
do {
|
||||
len = read(fd, buff, sizeof(buff));
|
||||
}
|
||||
archive_write_data(arc, buff, len);
|
||||
} while (len > 0);
|
||||
archive_write_finish_entry(arc);
|
||||
archive_entry_clear(entry);
|
||||
}
|
||||
archive_entry_free(entry);
|
||||
archive_write_close(arc);
|
||||
archive_write_free(arc);
|
||||
return true;
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -365,25 +365,23 @@ internal class LazyGridScrollbarAdapter(
|
||||
}
|
||||
|
||||
override fun averageVisibleLineSize(): Double {
|
||||
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
|
||||
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
|
||||
if (indexOfFirstKnownLineItem == -1) {
|
||||
val visibleItemsInfo = // Non-exiting visible items
|
||||
scrollState.layoutInfo.visibleItemsInfo.filter { it.line() != unknownLine }
|
||||
if (visibleItemsInfo.isEmpty()) {
|
||||
return 0.0
|
||||
}
|
||||
val reallyVisibleItemsInfo = // Non-exiting visible items
|
||||
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
|
||||
|
||||
// Compute the size of the last line
|
||||
val lastLine = reallyVisibleItemsInfo.last().line()
|
||||
val lastLineSize = reallyVisibleItemsInfo
|
||||
val last = visibleItemsInfo.last()
|
||||
val lastLine = last.line()
|
||||
val lastLineSize = visibleItemsInfo
|
||||
.asReversed()
|
||||
.asSequence()
|
||||
.takeWhile { it.line() == lastLine }
|
||||
.maxOf { it.mainAxisSize() }
|
||||
|
||||
val first = reallyVisibleItemsInfo.first()
|
||||
val last = reallyVisibleItemsInfo.last()
|
||||
val lineCount = last.line() - first.line() + 1
|
||||
val first = visibleItemsInfo.first()
|
||||
val lineCount = lastLine - first.line() + 1
|
||||
val lineSpacingSum = (lineCount - 1) * lineSpacing
|
||||
return (
|
||||
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
|
||||
|
||||
@@ -76,13 +76,11 @@ fun SearchBarInputField(
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading -> { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } } },
|
||||
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithLabel(),
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
@@ -102,5 +100,4 @@ fun SearchBarInputField(
|
||||
val SearchBarIconOffsetX = 4.dp
|
||||
val SearchBarMinWidth = 360.dp
|
||||
val SearchBarMaxWidth = 720.dp
|
||||
val SearchBarHorizontalPadding = 16.dp
|
||||
const val AnimationDelayMillis = 100
|
||||
-1640
File diff suppressed because it is too large.
Load diff
@@ -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.ktor.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)
|
||||
@@ -142,7 +143,6 @@ object Settings : DataStorePreferences(null) {
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
|
||||
var forceEhThumb by boolPref("force_eh_thumb", false)
|
||||
var showComments by boolPref("show_gallery_comments", true)
|
||||
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
var hideHvEvents by boolPref("hide_hv_events", false)
|
||||
@@ -163,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)
|
||||
@@ -171,13 +172,10 @@ 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)
|
||||
|
||||
// Tachiyomi Reader
|
||||
var newReader by boolPref("new_compose_reader", false)
|
||||
|
||||
// Reader
|
||||
val cropBorder = boolPref("crop_borders", false)
|
||||
val colorFilter = boolPref("pref_color_filter_key", false)
|
||||
val colorFilterValue = intPref("color_filter_value", 0)
|
||||
@@ -209,6 +207,12 @@ object Settings : DataStorePreferences(null) {
|
||||
val imageScaleType = intPref("pref_image_scale_type_key", 1)
|
||||
val landscapeZoom = boolPref("landscape_zoom", false)
|
||||
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) {
|
||||
@@ -216,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> {
|
||||
|
||||
@@ -15,32 +15,35 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
|
||||
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
|
||||
// ExHentai Large Preview: https://s.exhentai.org/t/***
|
||||
// ExHentai Normal Preview: https://s.exhentai.org/m/***
|
||||
// EHentai Normal Preview: https://ehgt.org/m/***
|
||||
// EHentai Large Preview: https://ehgt.org/***
|
||||
// E-Hentai Large Preview: https://ehgt.org/***
|
||||
|
||||
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
|
||||
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
|
||||
private const val NORMAL_PREVIEW_PREFIX = "$"
|
||||
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
|
||||
|
||||
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
|
||||
|
||||
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
|
||||
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
|
||||
|
||||
val GalleryPreview.url
|
||||
get() = if (exThumb) {
|
||||
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
|
||||
val String.isNormalPreviewKey
|
||||
get() = startsWith(NORMAL_PREVIEW_PREFIX)
|
||||
|
||||
val GalleryPreview.imageKey
|
||||
get() = if (this is NormalGalleryPreview) {
|
||||
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
|
||||
} else {
|
||||
URL_PREFIX_THUMB_E
|
||||
} + imageKey
|
||||
getThumbKey(url)
|
||||
}
|
||||
|
||||
val GalleryInfo.thumbUrl
|
||||
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
|
||||
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
|
||||
|
||||
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
|
||||
private val thumbPrefix
|
||||
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
@@ -29,6 +29,7 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.NoHitsFoundException
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parser.ArchiveParser
|
||||
@@ -55,13 +56,15 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
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
|
||||
@@ -96,7 +99,7 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -124,14 +127,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
|
||||
}
|
||||
|
||||
// Check bad response code
|
||||
if (code >= 400) {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
status.ensureSuccess()
|
||||
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
when (val message = e.cause?.message) {
|
||||
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
|
||||
"No hits found!" -> throw NoHitsFoundException()
|
||||
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
"Not logged in!" -> throw NotLoggedInException()
|
||||
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
|
||||
@@ -151,7 +152,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, body.left(), it)
|
||||
rethrowExactly(response.status, body.left(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
buffer.rewind()
|
||||
rethrowExactly(response.status.value, buffer.right(), it)
|
||||
rethrowExactly(response.status, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
@@ -45,13 +46,18 @@ import okio.BufferedSource
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsUtf8Text().parseAs<T>()
|
||||
}
|
||||
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
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"
|
||||
|
||||
|
||||
@@ -24,14 +24,13 @@ import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -143,11 +142,8 @@ object EhTagDatabase : CoroutineScope {
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
client.prepareGet(url).executeSafely {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
throw StatusCodeException(it.status.value)
|
||||
}
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
|
||||
@@ -60,14 +60,10 @@ object EhUrl {
|
||||
}
|
||||
|
||||
val apiUrl: String
|
||||
get() = if (Settings.forceEhThumb) {
|
||||
API_E
|
||||
} else {
|
||||
when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
get() = when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
|
||||
val referer: String
|
||||
|
||||
@@ -172,10 +172,7 @@ object EhUtils {
|
||||
return title.substringBeforeLast('|').trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun handleThumbUrlResolution(url: String?): String? {
|
||||
if (null == url) {
|
||||
return null
|
||||
}
|
||||
fun handleThumbUrlResolution(url: String): String {
|
||||
val resolution = when (Settings.thumbResolution) {
|
||||
0 -> return url
|
||||
1 -> "250"
|
||||
|
||||
@@ -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
|
||||
@@ -19,19 +19,19 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface GalleryPreview : Parcelable {
|
||||
val imageKey: String
|
||||
val url: String
|
||||
val position: Int
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class LargeGalleryPreview(
|
||||
override val imageKey: String,
|
||||
override val url: String,
|
||||
override val position: Int,
|
||||
) : GalleryPreview
|
||||
|
||||
@Parcelize
|
||||
data class NormalGalleryPreview(
|
||||
override val imageKey: String,
|
||||
override val url: String,
|
||||
override val position: Int,
|
||||
val offsetX: Int,
|
||||
val clipWidth: Int,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
|
||||
@@ -16,7 +16,6 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
@@ -32,7 +31,7 @@ object GalleryApiParser {
|
||||
title = item.title.unescapeXml()
|
||||
titleJpn = item.titleJpn.unescapeXml()
|
||||
category = getCategory(item.category)
|
||||
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
|
||||
thumbKey = getThumbKey(item.thumb)
|
||||
uploader = item.uploader?.unescapeXml()
|
||||
posted = ParserUtils.formatDate(item.posted * 1000)
|
||||
rating = item.rating
|
||||
|
||||
@@ -21,7 +21,6 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryCommentList
|
||||
@@ -62,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\\((.+?)\\)")
|
||||
@@ -140,11 +139,7 @@ object GalleryDetailParser {
|
||||
val gm = d.getElementsByClass("gm")[0]
|
||||
// Thumb url
|
||||
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
|
||||
gd.thumbKey = getThumbKey(
|
||||
PATTERN_COVER.find(it)?.run {
|
||||
handleThumbUrlResolution(groupValues[3])
|
||||
}!!,
|
||||
)
|
||||
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
|
||||
}
|
||||
|
||||
gd.title = gm.getElementById("gn")?.text()?.trim()
|
||||
@@ -458,9 +453,9 @@ object GalleryDetailParser {
|
||||
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[2].toInt() - 1
|
||||
val imageKey = getThumbKey(it.groupValues[3].trim())
|
||||
val url = it.groupValues[3].trim()
|
||||
val pageUrl = it.groupValues[1].trim()
|
||||
LargeGalleryPreview(imageKey, position) to pageUrl
|
||||
LargeGalleryPreview(url, position) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
@@ -470,12 +465,12 @@ object GalleryDetailParser {
|
||||
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[6].toInt() - 1
|
||||
val imageKey = getThumbKey(it.groupValues[3].trim())
|
||||
val url = it.groupValues[3].trim()
|
||||
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
|
||||
val width = it.groupValues[1].toInt()
|
||||
val height = it.groupValues[2].toInt()
|
||||
val pageUrl = it.groupValues[5].trim()
|
||||
NormalGalleryPreview(imageKey, position, xOffset, width, height) to pageUrl
|
||||
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -16,7 +16,7 @@ import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.url
|
||||
import com.hippo.ehviewer.client.imageKey
|
||||
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
|
||||
import io.ktor.http.HttpHeaders
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.coil
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.isNormalPreviewKey
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
@@ -12,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()
|
||||
@@ -24,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)
|
||||
}
|
||||
@@ -41,14 +43,14 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || 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 {
|
||||
@@ -62,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
|
||||
@@ -35,6 +36,7 @@ import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,24 +53,23 @@ 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
|
||||
private val hostJob = launch(start = CoroutineStart.LAZY) {
|
||||
private val hostJob = async(start = CoroutineStart.LAZY) {
|
||||
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
|
||||
pfd = file.openFileDescriptor("r")
|
||||
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size == 0) {
|
||||
return@launch
|
||||
}
|
||||
if (passwdProvider != null && needPassword()) {
|
||||
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size != 0 && passwdProvider != null && needPassword()) {
|
||||
archivePasswds?.forEach {
|
||||
it ?: return@forEach
|
||||
if (providePassword(it)) return@launch
|
||||
if (providePassword(it)) return@async size
|
||||
}
|
||||
val toAdd = passwdProvider { providePassword(it) }
|
||||
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
override var size = 0
|
||||
@@ -105,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)
|
||||
@@ -121,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 {
|
||||
@@ -136,17 +139,13 @@ class ArchivePageLoader(
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
hostJob.join()
|
||||
return super.awaitReady() && size != 0
|
||||
size = hostJob.await()
|
||||
return super.awaitReady() && isReady
|
||||
}
|
||||
|
||||
override val isReady: Boolean
|
||||
get() = size != 0
|
||||
|
||||
override fun onCancelRequest(index: Int) {
|
||||
mJobMap[index]?.cancel()
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
FileUtils.getNameFromFilename(file.name)!!
|
||||
}
|
||||
@@ -162,7 +161,7 @@ class ArchivePageLoader(
|
||||
false
|
||||
}
|
||||
|
||||
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
|
||||
}
|
||||
|
||||
private const val DEBUG_TAG = "ArchivePageLoader"
|
||||
@@ -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) {}
|
||||
@@ -105,7 +101,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
notifyPageFailed(index, error)
|
||||
}
|
||||
|
||||
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
|
||||
mSpiderQueen.preloadPages(pages, pair)
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
|
||||
mSpiderQueen.preloadPages(pages, bounds)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Path
|
||||
@@ -31,21 +31,19 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) :
|
||||
PageLoader(),
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private val progressJob = launch(start = CoroutineStart.LAZY) {
|
||||
private val progressJob = async(start = CoroutineStart.LAZY) {
|
||||
if (startPage == -1) {
|
||||
startPage = EhDB.getReadProgress(gid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
progressJob.start()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
progressJob.join()
|
||||
return startPage != -1
|
||||
progressJob.await()
|
||||
return super.awaitReady() && startPage != -1
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
package com.hippo.ehviewer.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Animatable
|
||||
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
|
||||
@@ -34,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,26 +52,25 @@ import java.nio.ByteBuffer
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
|
||||
val size = image.size
|
||||
val rect = when (image) {
|
||||
is BitmapImageWithRect -> image.rect
|
||||
else -> image.run { IntRect(0, 0, width, height) }
|
||||
class Image private constructor(image: CoilImage, private val src: ImageSource) {
|
||||
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
|
||||
}
|
||||
|
||||
var isRecyclable = true
|
||||
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
when (val image = innerImage ?: return) {
|
||||
is DrawableImage -> {
|
||||
(image.drawable as Animatable).stop()
|
||||
image.drawable.callback = null
|
||||
src.close()
|
||||
}
|
||||
is DrawableImage -> src.close()
|
||||
is BitmapImage -> image.bitmap.recycle()
|
||||
}
|
||||
innerImage = null
|
||||
@@ -79,7 +79,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
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()) }
|
||||
@@ -88,6 +88,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
detectQrCode(checkExtraneousAds)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
}
|
||||
return when (val result = appCtx.imageLoader.execute(req)) {
|
||||
@@ -96,7 +97,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decode(src: ImageSource): Image? {
|
||||
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image? {
|
||||
return runCatching {
|
||||
val image = when (src) {
|
||||
is PathSource -> {
|
||||
@@ -112,18 +113,18 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
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 {
|
||||
@@ -149,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)
|
||||
@@ -10,4 +10,4 @@ external fun getExtension(index: Int): String
|
||||
external fun needPassword(): Boolean
|
||||
external fun providePassword(str: String): Boolean
|
||||
external fun closeArchive()
|
||||
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int): Boolean
|
||||
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: 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
|
||||
|
||||
@@ -15,7 +15,6 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
import nl.adaptivity.xmlutil.XmlUtilInternal
|
||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||
import nl.adaptivity.xmlutil.newWriter
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
@@ -26,8 +25,6 @@ import okio.Path
|
||||
const val COMIC_INFO_FILE = "ComicInfo.xml"
|
||||
private const val TAG_ORIGINAL = "original"
|
||||
|
||||
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
|
||||
@OptIn(XmlUtilInternal::class)
|
||||
private val xml = XML {
|
||||
recommended {
|
||||
ignoreUnknownChildren()
|
||||
@@ -63,6 +60,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
|
||||
else -> simpleTags?.forEach { tagString ->
|
||||
val (namespace, tag) = tagString.split(':', limit = 2)
|
||||
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
|
||||
when (val ns = TagNamespace(namespace)) {
|
||||
Artist, Cosplayer -> artists.add(tag)
|
||||
Group -> groups.add(tag)
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +51,7 @@ import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -224,7 +226,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
|
||||
}
|
||||
|
||||
private val prepareJob = launch { doPrepare() }
|
||||
private val prepareJob = async { doPrepare() }
|
||||
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
|
||||
|
||||
private suspend fun doPrepare() {
|
||||
@@ -235,7 +237,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
suspend fun awaitReady(): Boolean {
|
||||
prepareJob.join()
|
||||
prepareJob.await()
|
||||
return isReady
|
||||
}
|
||||
|
||||
@@ -283,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)
|
||||
}
|
||||
@@ -506,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
|
||||
@@ -540,20 +533,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
val state = mPageStateArray[index]
|
||||
if (!force) {
|
||||
if (state == STATE_FINISHED) return
|
||||
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
if (!force && state == STATE_FINISHED) return
|
||||
val currentJob = mFetcherJobMap[index]
|
||||
val skipHath = force && !orgImg && currentJob?.isActive == true
|
||||
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
runCatching {
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg, skipHath)
|
||||
}
|
||||
@@ -590,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 {
|
||||
@@ -598,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
|
||||
@@ -693,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
|
||||
@@ -748,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()
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.ui
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -54,6 +55,7 @@ import com.hippo.ehviewer.download.downloadDir
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.download.tempDownloadDir
|
||||
import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
|
||||
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
@@ -68,7 +70,6 @@ import com.hippo.files.delete
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.openOutputStream
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -280,21 +281,14 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
|
||||
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
|
||||
)
|
||||
|
||||
context (Context, DestinationsNavigator)
|
||||
fun navToReader(
|
||||
info: BaseGalleryInfo,
|
||||
page: Int = -1,
|
||||
) {
|
||||
if (Settings.newReader) {
|
||||
navigate(ReaderScreenDestination(info, page))
|
||||
} else {
|
||||
val intent = Intent(this@Context, ReaderActivity::class.java)
|
||||
intent.action = ReaderActivity.ACTION_EH
|
||||
intent.putExtra(ReaderActivity.KEY_GALLERY_INFO, info)
|
||||
intent.putExtra(ReaderActivity.KEY_PAGE, page)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
|
||||
navToReader(ReaderScreenArgs.Gallery(info, page))
|
||||
|
||||
fun DestinationsNavigator.navToReader(uri: Uri) =
|
||||
navToReader(ReaderScreenArgs.Archive(uri))
|
||||
|
||||
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
|
||||
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
|
||||
|
||||
context(DialogState, Context, DestinationsNavigator)
|
||||
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.hippo.ehviewer.ui.screen.implicit
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.togetherWith
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@Composable
|
||||
inline fun <R> composing(
|
||||
inline fun <R> AnimatedVisibilityScope.composing(
|
||||
navigator: DestinationsNavigator,
|
||||
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
|
||||
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
|
||||
() -> R,
|
||||
) = block(
|
||||
LocalContext.current.run { remember { findActivity<MainActivity>() } },
|
||||
LocalSnackBarHostState.current,
|
||||
LocalDialogState.current,
|
||||
navigator,
|
||||
rememberCoroutineScope(),
|
||||
)
|
||||
) = with(NoopTransitionsVisibilityScope) {
|
||||
togetherWith(implicit<AnimatedVisibilityScope>()) {
|
||||
block(
|
||||
with(LocalContext.current) { remember { findActivity() } },
|
||||
LocalSnackBarHostState.current,
|
||||
LocalDialogState.current,
|
||||
LocalSharedTransitionScope.current,
|
||||
this,
|
||||
navigator,
|
||||
rememberCoroutineScope(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -17,6 +17,8 @@ package com.hippo.ehviewer.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.ContentResolver.SCHEME_CONTENT
|
||||
import android.content.ContentResolver.SCHEME_FILE
|
||||
import android.content.Intent
|
||||
import android.content.pm.verify.domain.DomainVerificationManager
|
||||
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
|
||||
@@ -24,6 +26,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -134,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
|
||||
@@ -147,7 +151,7 @@ import com.hippo.ehviewer.util.sha1
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.ramcosta.composedestinations.spec.Direction
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
@@ -162,7 +166,7 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.clipboardManager
|
||||
import splitties.systemservices.connectivityManager
|
||||
|
||||
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
|
||||
private val navItems = arrayOf<Triple<Direction, Int, ImageVector>>(
|
||||
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
|
||||
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
|
||||
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
|
||||
@@ -230,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,
|
||||
@@ -244,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,10 +275,19 @@ class MainActivity : EhActivity() {
|
||||
intentFlow.collect { intent ->
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
val uri = intent.data ?: return@collect
|
||||
when (uri.scheme) {
|
||||
SCHEME_CONTENT, SCHEME_FILE -> {
|
||||
navigator.navToReader(uri)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val url = uri.toString()
|
||||
if (!navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND -> {
|
||||
@@ -450,12 +469,22 @@ class MainActivity : EhActivity() {
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && drawerEnabled,
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = 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,
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,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 = {
|
||||
@@ -508,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"),
|
||||
@@ -537,6 +566,7 @@ val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionL
|
||||
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
|
||||
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
|
||||
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
|
||||
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope not present!") }
|
||||
|
||||
@Composable
|
||||
fun DrawerHandle(enabled: Boolean) {
|
||||
|
||||
@@ -5,10 +5,10 @@ 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
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
@@ -19,20 +19,8 @@ import soup.compose.material.motion.animation.materialSharedAxisXIn
|
||||
import soup.compose.material.motion.animation.materialSharedAxisXOut
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
|
||||
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
|
||||
ProvideVectorPainterCache {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
@@ -45,15 +33,17 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
|
||||
}
|
||||
}
|
||||
|
||||
private typealias Ty = AnimatedContentTransitionScope<NavBackStackEntry>
|
||||
|
||||
@Composable
|
||||
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
return remember(slideDistance) {
|
||||
object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
|
||||
override val enterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
|
||||
override val exitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
|
||||
override val popEnterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
|
||||
override val popExitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,27 @@ import android.widget.TextView
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
class CoilImageGetter(
|
||||
private val textView: TextView,
|
||||
) : Html.ImageGetter {
|
||||
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
|
||||
textView.context.imageLoader.enqueue(
|
||||
textView.context.imageRequest {
|
||||
data(source)
|
||||
crossfade(false)
|
||||
target { drawable ->
|
||||
setDrawable(drawable.asDrawable(textView.resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
}
|
||||
},
|
||||
)
|
||||
with(textView.context) {
|
||||
imageLoader.enqueue(
|
||||
imageRequest {
|
||||
data(source)
|
||||
crossfade(false)
|
||||
size(Size.ORIGINAL)
|
||||
target { drawable ->
|
||||
setDrawable(drawable.asDrawable(resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -43,6 +44,8 @@ import com.hippo.ehviewer.dao.DownloadInfo
|
||||
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)
|
||||
@@ -90,6 +93,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun DownloadCard(
|
||||
onClick: () -> Unit,
|
||||
@@ -105,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) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -7,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.Image
|
||||
@@ -14,6 +17,8 @@ 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
|
||||
|
||||
@Composable
|
||||
@@ -23,6 +28,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
|
||||
return remember(model) { context.imageRequest(model) }
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun EhAsyncThumb(
|
||||
@@ -33,13 +39,16 @@ fun EhAsyncThumb(
|
||||
) = AsyncImage(
|
||||
model = requestOf(model),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
// .sharedBounds(key = "${model.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = onSuccess?.let { callback ->
|
||||
{ callback(it.result.image) }
|
||||
},
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
fun EhAsyncCropThumb(
|
||||
key: GalleryInfo,
|
||||
@@ -49,7 +58,9 @@ fun EhAsyncCropThumb(
|
||||
AsyncImage(
|
||||
model = requestOf(key),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
// .sharedBounds(key = "${key.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
@@ -36,6 +36,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
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(
|
||||
@@ -96,6 +98,7 @@ fun GalleryDetailHeaderInfoCard(
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryDetailHeaderCard(
|
||||
info: GalleryInfo,
|
||||
@@ -107,10 +110,15 @@ fun GalleryDetailHeaderCard(
|
||||
) = ElevatedCard(modifier = modifier) {
|
||||
Row {
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.gallery_detail_thumb_height)).width(dimensionResource(id = R.dimen.gallery_detail_thumb_width)),
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -51,6 +52,8 @@ import com.hippo.ehviewer.download.DownloadManager
|
||||
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)
|
||||
@@ -88,6 +91,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryInfoListItem(
|
||||
onClick: () -> Unit,
|
||||
@@ -105,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(),
|
||||
@@ -199,6 +205,7 @@ fun GalleryInfoListItem(
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryInfoGridItem(
|
||||
onClick: () -> Unit,
|
||||
@@ -223,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),
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.paging.compose.itemKey
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.NoHitsFoundException
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
@@ -116,6 +117,11 @@ fun GalleryList(
|
||||
}
|
||||
},
|
||||
) {
|
||||
val showLoadStateIndicator = when (val state = data.loadState.append) {
|
||||
LoadState.Loading -> true
|
||||
is LoadState.Error -> state.error !is NoHitsFoundException
|
||||
is LoadState.NotLoading -> false
|
||||
}
|
||||
if (listMode == 0) {
|
||||
val columnWidth by collectDetailSizeAsState()
|
||||
FastScrollLazyVerticalGrid(
|
||||
@@ -136,7 +142,7 @@ fun GalleryList(
|
||||
detailItemContent(info)
|
||||
}
|
||||
}
|
||||
if (data.loadState.append !is LoadState.NotLoading) {
|
||||
if (showLoadStateIndicator) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
LoadStateIndicator(state = data.loadState.append) {
|
||||
data.retry()
|
||||
@@ -165,7 +171,7 @@ fun GalleryList(
|
||||
thumbItemContent(info)
|
||||
}
|
||||
}
|
||||
if (data.loadState.append !is LoadState.NotLoading) {
|
||||
if (showLoadStateIndicator) {
|
||||
item(span = StaggeredGridItemSpan.FullLine) {
|
||||
LoadStateIndicator(state = data.loadState.append) {
|
||||
data.retry()
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -151,23 +153,27 @@ fun SearchFilter(
|
||||
val (from, to) = dialogState.awaitResult(
|
||||
initial = advancedOption.fromPage to advancedOption.toPage,
|
||||
title = R.string.key_pages,
|
||||
invalidator = { (min, _max) ->
|
||||
// 0 means max pages not filled, though +inf
|
||||
val max = if (_max == 0) Int.MAX_VALUE else _max
|
||||
if (min != 0) {
|
||||
ensure(max - min >= 20) { pageErr2 }
|
||||
} else {
|
||||
ensure(max >= 10) { pageErr1 }
|
||||
invalidator = { (min, max) ->
|
||||
if (max != 0) {
|
||||
if (min != 0) {
|
||||
ensure(min <= (max / 2).coerceAtMost(max - 20)) { pageErr2 }
|
||||
} else {
|
||||
ensure(max >= 10) { pageErr1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
) { error ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceIn(0, 1000))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
@@ -178,7 +184,7 @@ fun SearchFilter(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceIn(0, 2000))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
@@ -188,11 +194,14 @@ fun SearchFilter(
|
||||
}
|
||||
if (error != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = error) },
|
||||
headlineContent = {
|
||||
Text(text = error, style = MaterialTheme.typography.bodySmall)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null)
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.error,
|
||||
leadingIconColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.hippo.ehviewer.Settings
|
||||
import me.saket.telephoto.zoomable.DoubleClickToZoomListener
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
|
||||
object DoubleTapZoom : DoubleClickToZoomListener {
|
||||
override suspend fun onDoubleClick(state: ZoomableState, centroid: Offset) {
|
||||
if (Settings.doubleTapToZoom.value) {
|
||||
val zoomFraction = state.zoomFraction ?: return // Content isn't ready yet
|
||||
if (zoomFraction > 0.05f) {
|
||||
state.resetZoom()
|
||||
} else {
|
||||
// Workaround for https://github.com/saket/telephoto/issues/45
|
||||
state.zoomTo(
|
||||
zoomFactor = state.contentTransformation.scaleMetadata.initialScale.scaleX * 2f,
|
||||
centroid = centroid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,36 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.source.model.Page
|
||||
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.DEFAULT
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.LEFT_TO_RIGHT
|
||||
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.ReadingModeType.WEBTOON
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
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(
|
||||
@@ -37,119 +38,82 @@ fun GalleryPager(
|
||||
pagerState: PagerState,
|
||||
lazyListState: LazyListState,
|
||||
pageLoader: PageLoader2,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
showNavigationOverlay: Boolean,
|
||||
onNavigationModeChange: () -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val items = pageLoader.pages
|
||||
when (type) {
|
||||
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = type == RIGHT_TO_LEFT,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { onSelectPage(page) },
|
||||
)
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = { onMenuRegionClick() },
|
||||
).fillMaxSize()
|
||||
}
|
||||
Box(
|
||||
modifier = boxModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VERTICAL -> {
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { onSelectPage(page) },
|
||||
)
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = { onMenuRegionClick() },
|
||||
).fillMaxSize()
|
||||
}
|
||||
Box(
|
||||
modifier = boxModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
WEBTOON, CONTINUOUS_VERTICAL -> {
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
LazyColumn(
|
||||
modifier = modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { ofs ->
|
||||
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
|
||||
info.offset <= ofs.y && info.offset + info.size > ofs.y
|
||||
}
|
||||
if (info == null && type == CONTINUOUS_VERTICAL) {
|
||||
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
|
||||
return@zoomable
|
||||
}
|
||||
info ?: error("Internal error finding long click item!!! offset:$ofs")
|
||||
onSelectPage(items[info.index])
|
||||
},
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val isPagerType = !ReadingModeType.isWebtoon(type)
|
||||
val pagerNavigation by Settings.readerPagerNav.collectAsState()
|
||||
val pagerInvertMode by Settings.readerPagerNavInverted.collectAsState()
|
||||
val webtoonNavigation by Settings.readerWebtoonNav.collectAsState()
|
||||
val webtoonInvertMode by Settings.readerWebtoonNavInverted.collectAsState()
|
||||
val navigationType = if (isPagerType) pagerNavigation else webtoonNavigation
|
||||
val navigation = remember(navigationType, type) {
|
||||
ViewerNavigation.fromPreference(navigationType, ReadingModeType.isVertical(type))
|
||||
}
|
||||
val invertMode = if (isPagerType) pagerInvertMode else webtoonInvertMode
|
||||
var firstLaunch by remember { mutableStateOf(true) }
|
||||
val regions = remember(navigation, invertMode) {
|
||||
if (firstLaunch) {
|
||||
firstLaunch = false
|
||||
} else {
|
||||
onNavigationModeChange()
|
||||
}
|
||||
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,
|
||||
isVertical = type == VERTICAL,
|
||||
pageLoader = pageLoader,
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
modifier = modifier.pointerInput(channel) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitScrollEvent()
|
||||
val delta = calculateMouseWheelScroll(event)
|
||||
channel.trySend(delta)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
WebtoonViewer(
|
||||
lazyListState = lazyListState,
|
||||
withGaps = type == CONTINUOUS_VERTICAL,
|
||||
pageLoader = pageLoader,
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
private val zoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
|
||||
@Composable
|
||||
fun NavigationOverlay(
|
||||
visible: Boolean,
|
||||
regions: NavigationRegions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible && regions.isNotEmpty(),
|
||||
enter = fadeIn(AnimationSpec),
|
||||
exit = fadeOut(AnimationSpec),
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val fontSize = MaterialTheme.typography.headlineSmall.fontSize
|
||||
val style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = fontSize,
|
||||
)
|
||||
val strokeStyle = TextStyle(
|
||||
color = Color.Black,
|
||||
fontSize = fontSize,
|
||||
drawStyle = Stroke(8f),
|
||||
)
|
||||
val scaleX = maxWidth
|
||||
val scaleY = maxHeight
|
||||
regions.forEach { region ->
|
||||
val rect = region.rect
|
||||
val color = colorResource(region.type.colorRes)
|
||||
val text = stringResource(region.type.nameRes)
|
||||
Box(
|
||||
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)
|
||||
Text(text, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val AnimationSpec = tween<Float>(1000)
|
||||
@@ -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))
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -19,61 +20,77 @@ 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.IntRect
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import coil3.BitmapImage
|
||||
import coil3.DrawableImage
|
||||
import coil3.Image
|
||||
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 eu.kanade.tachiyomi.source.model.Page
|
||||
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 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 me.saket.telephoto.zoomable.ZoomableContentLocation
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
@Composable
|
||||
fun PagerItem(
|
||||
page: ReaderPage,
|
||||
page: Page,
|
||||
pageLoader: PageLoader,
|
||||
zoomableState: ZoomableState,
|
||||
webtoon: Boolean,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier = Modifier,
|
||||
contentModifier: Modifier = Modifier,
|
||||
) {
|
||||
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 -> {
|
||||
LaunchedEffect(Unit) {
|
||||
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)
|
||||
when (val state = page.statusObserved) {
|
||||
is PageStatus.Queued, is PageStatus.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
|
||||
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!!.innerImage!!
|
||||
val rect = page.image!!.rect
|
||||
val painter = remember(image) { image.toPainter(rect) }
|
||||
val contentScale = if (webtoon) ContentScale.FillWidth else ContentScale.Inside
|
||||
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()
|
||||
DisposableEffect(image) {
|
||||
image.isRecyclable = false
|
||||
onDispose {
|
||||
if (image.isRecyclable) {
|
||||
pageLoader.notifyPageWait(page.index)
|
||||
image.recycle()
|
||||
} else {
|
||||
image.isRecyclable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = contentModifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
colorFilter = when {
|
||||
grayScale && invert -> grayScaleAndInvertFilter
|
||||
@@ -82,22 +99,37 @@ fun PagerItem(
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
if (!webtoon) {
|
||||
val size = painter.intrinsicSize
|
||||
LaunchedEffect(size) {
|
||||
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
|
||||
zoomableState.setContentLocation(contentLocation)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Page.State.ERROR -> {
|
||||
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
|
||||
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,
|
||||
@@ -111,13 +143,9 @@ fun PagerItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Image.toPainter(rect: IntRect) = when (this) {
|
||||
is BitmapImage -> BitmapPainter(
|
||||
image = bitmap.asImageBitmap(),
|
||||
srcOffset = rect.topLeft,
|
||||
srcSize = rect.size,
|
||||
)
|
||||
is DrawableImage -> DrawablePainter(drawable)
|
||||
private fun Image.toPainter() = when (val image = innerImage) {
|
||||
is BitmapImage -> BitmapPainter(image.bitmap, intrinsicSize.toSize())
|
||||
is DrawableImage -> DrawablePainter(image.drawable)
|
||||
else -> unreachable()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.FixedScale
|
||||
import androidx.compose.ui.layout.times
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
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 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
|
||||
import kotlin.contracts.contract
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.ZoomableContentLocation
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun PagerViewer(
|
||||
pagerState: PagerState,
|
||||
isRtl: Boolean,
|
||||
isVertical: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val items = pageLoader.pages
|
||||
val scaleType by Settings.imageScaleType.collectAsState()
|
||||
val landscapeZoom by Settings.landscapeZoom.collectAsState()
|
||||
val zoomStart by Settings.zoomStart.collectAsState()
|
||||
val alignment = Alignment.fromPreferences(zoomStart, isRtl, isVertical)
|
||||
val layoutSize by remember(pagerState) {
|
||||
derivedStateOf {
|
||||
pagerState.layoutInfo.viewportSize.toSize()
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
PageContainer(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
isRtl = false,
|
||||
scaleType = scaleType,
|
||||
landscapeZoom = landscapeZoom,
|
||||
alignment = alignment,
|
||||
layoutSize = layoutSize,
|
||||
navigator = navigator,
|
||||
pagerState = pagerState,
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val isRtlLayout = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
reverseLayout = isRtl xor isRtlLayout,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
PageContainer(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
isRtl = isRtl,
|
||||
scaleType = scaleType,
|
||||
landscapeZoom = landscapeZoom,
|
||||
alignment = alignment,
|
||||
layoutSize = layoutSize,
|
||||
navigator = navigator,
|
||||
pagerState = pagerState,
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageContainer(
|
||||
page: Page,
|
||||
pageLoader: PageLoader2,
|
||||
isRtl: Boolean,
|
||||
scaleType: Int,
|
||||
landscapeZoom: Boolean,
|
||||
alignment: Alignment.Horizontal,
|
||||
layoutSize: Size,
|
||||
navigator: () -> NavigationRegions,
|
||||
pagerState: PagerState,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val isRtl by rememberUpdatedState(isRtl)
|
||||
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
|
||||
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) {
|
||||
val contentSize = if (contentScale is FixedScale) { // Original
|
||||
size
|
||||
} else {
|
||||
size * contentScale.computeScaleFactor(size, layoutSize)
|
||||
}
|
||||
val horizontalAlignment = if (contentSize.width > layoutSize.width) {
|
||||
alignment
|
||||
} else {
|
||||
Alignment.CenterHorizontally
|
||||
}
|
||||
val verticalAlignment = if (contentSize.height > layoutSize.height) {
|
||||
Alignment.Top
|
||||
} else {
|
||||
Alignment.CenterVertically
|
||||
}
|
||||
zoomableState.contentAlignment = horizontalAlignment + verticalAlignment
|
||||
}
|
||||
LaunchedEffect(size) {
|
||||
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
|
||||
zoomableState.setContentLocation(contentLocation)
|
||||
}
|
||||
if (landscapeZoom && contentScale == ContentScale.Fit && size.width > size.height) {
|
||||
LaunchedEffect(alignment) {
|
||||
val zoomFraction = snapshotFlow { zoomableState.zoomFraction }.first { it != null }
|
||||
if (zoomFraction == 0f) {
|
||||
delay(500)
|
||||
val contentSize = zoomableState.transformedContentBounds.size
|
||||
val scale = ContentScale.FillHeight.computeScaleFactor(contentSize, layoutSize)
|
||||
val targetScale = scale.scaleX.coerceAtMost(zoomableState.zoomSpec.maxZoomFactor)
|
||||
val offset = alignment.align(0, layoutSize.width.toInt(), LayoutDirection.Ltr)
|
||||
zoomableState.zoomTo(targetScale, Offset(offset.toFloat(), 0f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val onLongClick = { _: Offset -> onSelectPage(page) }
|
||||
val onTap: ZoomableState?.(Offset) -> Unit = { offset ->
|
||||
scope.launch {
|
||||
with(pagerState) {
|
||||
// Don't use `layoutSize` as it may capture outdated value
|
||||
val size = layoutInfo.viewportSize.toSize()
|
||||
val (w, h) = size
|
||||
val (x, y) = offset
|
||||
val distance = size.width
|
||||
val bounds = size.toRect()
|
||||
when (navigator().getAction(Offset(x / w, y / h))) {
|
||||
NavigationRegion.MENU -> onMenuRegionClick()
|
||||
NavigationRegion.NEXT -> {
|
||||
val canPan = if (isRtl) panRight(distance, bounds) else panLeft(distance, bounds)
|
||||
if (!canPan) {
|
||||
moveToNext()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.PREV -> {
|
||||
val canPan = if (isRtl) panLeft(distance, bounds) else panRight(distance, bounds)
|
||||
if (!canPan) {
|
||||
moveToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.RIGHT -> {
|
||||
if (!panLeft(distance, bounds)) {
|
||||
if (isRtl) moveToPrevious() else moveToNext()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.LEFT -> {
|
||||
if (!panRight(distance, bounds)) {
|
||||
if (isRtl) moveToNext() else moveToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
contentScale = ContentScale.Inside,
|
||||
modifier = Modifier.pointerInput(onTap) {
|
||||
detectTapGestures(onLongPress = onLongClick, onTap = onTap.partially1(null))
|
||||
},
|
||||
contentModifier = Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = onTap.partially1(zoomableState),
|
||||
onLongClick = onLongClick,
|
||||
onDoubleClick = DoubleTapZoom,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ZoomableState?.panLeft(distance: Float, bounds: Rect): Boolean =
|
||||
if (canPan { it.right - bounds.right }) {
|
||||
panBy(Offset(-distance, 0f))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun ZoomableState?.panRight(distance: Float, bounds: Rect): Boolean =
|
||||
if (canPan { bounds.left - it.left }) {
|
||||
panBy(Offset(distance, 0f))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
private inline fun ZoomableState?.canPan(getRemaining: (Rect) -> Float): Boolean {
|
||||
// TODO: Remove when K2 mode in IDE is stable
|
||||
contract {
|
||||
returns(true) implies (this@canPan != null)
|
||||
}
|
||||
return this != null && Settings.navigateToPan.value && getRemaining(transformedContentBounds) > 1f
|
||||
}
|
||||
|
||||
private val PagerZoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
@@ -18,9 +22,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -28,28 +34,44 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.ensure
|
||||
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.tools.Deferred
|
||||
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
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -59,27 +81,99 @@ import eu.kanade.tachiyomi.ui.reader.ReaderContentOverlay
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderPageSheetMeta
|
||||
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
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
sealed interface ReaderScreenArgs : Parcelable {
|
||||
@Parcelize
|
||||
data class Gallery(val info: BaseGalleryInfo, val page: Int) : ReaderScreenArgs
|
||||
|
||||
@Parcelize
|
||||
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 ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
ConfigureKeepScreenOn()
|
||||
val pageLoader = remember {
|
||||
val archive = DownloadManager.getDownloadInfo(info.gid)?.archiveFile
|
||||
archive?.let { ArchivePageLoader(it, info.gid, page) } ?: EhPageLoader(info, page)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.cropBorder.changesFlow().collect {
|
||||
pageLoader.restart()
|
||||
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
Await({ preparePageLoader(args) }) { pageLoader ->
|
||||
DisposableEffect(pageLoader) {
|
||||
pageLoader.start()
|
||||
onDispose {
|
||||
pageLoader.stop()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
readingFailed.takeUnless { pageLoader.awaitReady() }
|
||||
}.fold({ it.displayString() }, { it })
|
||||
},
|
||||
placeholder = {
|
||||
Background(bgColor) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
) { error ->
|
||||
if (error == null) {
|
||||
val info = (args as? ReaderScreenArgs.Gallery)?.info
|
||||
key(pageLoader) {
|
||||
ReaderScreen(pageLoader, info, navigator)
|
||||
}
|
||||
} else {
|
||||
Background(bgColor) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(pageLoader) {
|
||||
pageLoader.start()
|
||||
onDispose {
|
||||
pageLoader.stop()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGalleryInfo?, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
ConfigureKeepScreenOn()
|
||||
LaunchedEffect(Unit) {
|
||||
val orientation = requestedOrientation
|
||||
Settings.orientationMode.valueFlow()
|
||||
.onCompletion { requestedOrientation = orientation }
|
||||
.collect { setOrientation(it) }
|
||||
}
|
||||
LaunchedEffect(pageLoader) {
|
||||
with(Settings) {
|
||||
merge(cropBorder.changesFlow(), stripExtraneousAds.changesFlow()).collect {
|
||||
pageLoader.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
@@ -95,132 +189,186 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
}
|
||||
}
|
||||
Deferred({ pageLoader.awaitReady() }) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val pagerState = rememberPagerState { pageLoader.size }
|
||||
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
|
||||
syncState.Sync(ReadingModeType.isWebtoon(readingMode))
|
||||
Box {
|
||||
var appbarVisible by remember { mutableStateOf(false) }
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
if (fullscreen) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { appbarVisible }.collect {
|
||||
uiController.isSystemBarsVisible = it
|
||||
val lazyListState = rememberLazyListState(pageLoader.startPage)
|
||||
val pagerState = rememberPagerState(pageLoader.startPage) { pageLoader.size }
|
||||
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
|
||||
Box {
|
||||
var appbarVisible by remember { mutableStateOf(false) }
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
val isWebtoon by rememberUpdatedState(ReadingModeType.isWebtoon(readingMode))
|
||||
syncState.Sync(isWebtoon) { appbarVisible = false }
|
||||
if (fullscreen) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { appbarVisible }.collect {
|
||||
uiController.isSystemBarsVisible = it
|
||||
}
|
||||
}
|
||||
}
|
||||
VolumeKeysHandler(
|
||||
enabled = { volumeKeysEnabled && !appbarVisible },
|
||||
movePrevious = {
|
||||
if (isWebtoon) lazyListState.scrollUp() else pagerState.moveToPrevious()
|
||||
},
|
||||
moveNext = {
|
||||
if (isWebtoon) lazyListState.scrollDown() else pagerState.moveToNext()
|
||||
},
|
||||
)
|
||||
var showNavigationOverlay by remember {
|
||||
val showOnStart = Settings.showNavigationOverlayNewUser.value || Settings.showNavigationOverlayOnStart.value
|
||||
Settings.showNavigationOverlayNewUser.value = false
|
||||
mutableStateOf(showOnStart)
|
||||
}
|
||||
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() } } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VolumeKeysHandler(
|
||||
enabled = { volumeKeysEnabled && !appbarVisible },
|
||||
movePrevious = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
|
||||
moveNext = { syncState.sliderScrollTo(syncState.sliderValue + 1) },
|
||||
)
|
||||
}
|
||||
EhTheme(useDarkTheme = bgColor != Color.White) {
|
||||
val insets = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
EmptyWindowInsets
|
||||
} else {
|
||||
WindowInsets.displayCutout
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars
|
||||
}
|
||||
GalleryPager(
|
||||
type = readingMode,
|
||||
pagerState = pagerState,
|
||||
lazyListState = lazyListState,
|
||||
pageLoader = pageLoader,
|
||||
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() } } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
showNavigationOverlay = showNavigationOverlay,
|
||||
onNavigationModeChange = { showNavigationOverlay = true },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor),
|
||||
contentPadding = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
PaddingValues(0.dp)
|
||||
} else {
|
||||
WindowInsets.displayCutout.asPaddingValues()
|
||||
modifier = Modifier.background(bgColor).pointerInput(syncState) {
|
||||
awaitEachGesture {
|
||||
waitForUpOrCancellation()
|
||||
syncState.reset()
|
||||
showNavigationOverlay = false
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars.asPaddingValues()
|
||||
},
|
||||
}.fillMaxSize().windowInsetsPadding(insets),
|
||||
)
|
||||
val brightness by Settings.customBrightness.collectAsState()
|
||||
val brightnessValue by Settings.customBrightnessValue.collectAsState()
|
||||
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
|
||||
val colorOverlay by Settings.colorFilterValue.collectAsState()
|
||||
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
|
||||
when (it) {
|
||||
0 -> BlendMode.SrcOver
|
||||
1 -> BlendMode.Multiply
|
||||
2 -> BlendMode.Screen
|
||||
3 -> BlendMode.Overlay
|
||||
4 -> BlendMode.Lighten
|
||||
5 -> BlendMode.Darken
|
||||
else -> unreachable()
|
||||
}
|
||||
}
|
||||
val brightness by Settings.customBrightness.collectAsState()
|
||||
val brightnessValue by Settings.customBrightnessValue.collectAsState()
|
||||
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
|
||||
val colorOverlay by Settings.colorFilterValue.collectAsState()
|
||||
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
|
||||
when (it) {
|
||||
0 -> BlendMode.SrcOver
|
||||
1 -> BlendMode.Multiply
|
||||
2 -> BlendMode.Screen
|
||||
3 -> BlendMode.Overlay
|
||||
4 -> BlendMode.Lighten
|
||||
5 -> BlendMode.Darken
|
||||
else -> unreachable()
|
||||
}
|
||||
ReaderContentOverlay(
|
||||
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
|
||||
color = { colorOverlay }.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
val showPageNumber by Settings.showPageNumber.collectAsState()
|
||||
if (showPageNumber && !appbarVisible) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
|
||||
PageIndicatorText(
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ReaderContentOverlay(
|
||||
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
|
||||
color = { colorOverlay }.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
if (brightness) {
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.customBrightnessValue.valueFlow().sample(100)
|
||||
.onCompletion { setCustomBrightnessValue(0) }
|
||||
.collect { setCustomBrightnessValue(it) }
|
||||
}
|
||||
ReaderAppBars(
|
||||
visible = appbarVisible,
|
||||
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
|
||||
showSeekBar = showSeekbar,
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
|
||||
onClickSettings = {
|
||||
launch {
|
||||
dialog { cont ->
|
||||
fun dispose() = cont.resume(Unit)
|
||||
var isColorFilter by remember { mutableStateOf(false) }
|
||||
val scrim by animateColorAsState(
|
||||
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
|
||||
label = "ScrimColor",
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
appbarVisible = !isColorFilter
|
||||
}
|
||||
}
|
||||
val showPageNumber by Settings.showPageNumber.collectAsState()
|
||||
if (showPageNumber && !appbarVisible) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
|
||||
PageIndicatorText(
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ReaderAppBars(
|
||||
visible = appbarVisible,
|
||||
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
|
||||
showSeekBar = showSeekbar,
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
|
||||
onClickSettings = {
|
||||
launch {
|
||||
dialog { cont ->
|
||||
fun dispose() = cont.resume(Unit)
|
||||
var isColorFilter by remember { mutableStateOf(false) }
|
||||
val scrim by animateColorAsState(
|
||||
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
|
||||
label = "ScrimColor",
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
contentWindowInsets = { EmptyWindowInsets },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
appbarVisible = !isColorFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context(Context, DialogState, DestinationsNavigator)
|
||||
private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
|
||||
is ReaderScreenArgs.Gallery -> {
|
||||
val info = args.info
|
||||
val page = args.page
|
||||
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
|
||||
if (archive != null) {
|
||||
ArchivePageLoader(archive, info.gid, page, info.hasAds)
|
||||
} else {
|
||||
EhPageLoader(info, page)
|
||||
}
|
||||
}
|
||||
is ReaderScreenArgs.Archive -> {
|
||||
ArchivePageLoader(args.uri.toOkioPath()) { invalidator ->
|
||||
awaitInputText(
|
||||
title = getString(R.string.archive_need_passwd),
|
||||
hint = getString(R.string.archive_passwd),
|
||||
onUserDismiss = { popBackStack() },
|
||||
) { text ->
|
||||
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
|
||||
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -8,12 +7,11 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
@@ -23,8 +21,7 @@ class SliderPagerDoubleSync(
|
||||
private val pagerState: PagerState,
|
||||
private val pageLoader: PageLoader2,
|
||||
) {
|
||||
private var requestScroll = true
|
||||
private var sliderFollowPager = true
|
||||
private var sliderFollowPager by mutableStateOf(true)
|
||||
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
|
||||
private set
|
||||
|
||||
@@ -33,14 +30,16 @@ class SliderPagerDoubleSync(
|
||||
sliderValue = index.coerceIn(1, pageLoader.size)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
sliderFollowPager = true
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sync(webtoon: Boolean) {
|
||||
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
|
||||
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
if (fling || pagerFling) sliderFollowPager = true
|
||||
fun Sync(webtoon: Boolean, onPageSelected: () -> Unit) {
|
||||
val currentIndexFlow = remember(webtoon) {
|
||||
requestScroll = true
|
||||
val initialIndex = sliderValue - 1
|
||||
if (webtoon) {
|
||||
sliderFollowPager = lazyListState.firstVisibleItemIndex == initialIndex
|
||||
snapshotFlow {
|
||||
with(lazyListState.layoutInfo) {
|
||||
visibleItemsInfo.lastOrNull {
|
||||
@@ -49,44 +48,28 @@ class SliderPagerDoubleSync(
|
||||
}
|
||||
}.filterNotNull()
|
||||
} else {
|
||||
sliderFollowPager = pagerState.currentPage == initialIndex
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
if (sliderFollowPager && !requestScroll) {
|
||||
if (sliderFollowPager) {
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
sliderValue = index + 1
|
||||
pageLoader.startPage = index
|
||||
onPageSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (sliderFollowPager && !requestScroll) return@collectLatest
|
||||
if (webtoon) {
|
||||
if (requestScroll) {
|
||||
lazyListState.requestScrollToItem(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (webtoon) {
|
||||
lazyListState.scrollToItem(index)
|
||||
} else {
|
||||
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (requestScroll) {
|
||||
pagerState.requestScrollToPage(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
if (smoothScroll((index - pagerState.currentPage))) {
|
||||
pagerState.animateScrollToPage(index)
|
||||
} else {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,8 +84,3 @@ fun rememberSliderPagerDoubleSyncState(
|
||||
): SliderPagerDoubleSync = remember {
|
||||
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
|
||||
}
|
||||
|
||||
private fun smoothScroll(distance: Int) =
|
||||
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
|
||||
|
||||
private const val SMOOTH_SCROLL_THRESHOLD = 50
|
||||
@@ -3,13 +3,23 @@ package com.hippo.ehviewer.ui.reader
|
||||
import android.app.Activity
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.AbsoluteAlignment
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.FixedScale
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
|
||||
private fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
@@ -19,11 +29,98 @@ private fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
|
||||
@Composable
|
||||
fun Activity.ConfigureKeepScreenOn() {
|
||||
val on by Settings.keepScreenOn.collectAsState()
|
||||
DisposableEffect(on) {
|
||||
window.updateKeepScreenOn(on)
|
||||
onDispose {
|
||||
window.updateKeepScreenOn(false)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.keepScreenOn.valueFlow()
|
||||
.onCompletion { window.updateKeepScreenOn(false) }
|
||||
.collect { window.updateKeepScreenOn(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the user preferred [orientation] on the activity.
|
||||
*/
|
||||
fun Activity.setOrientation(orientation: Int) {
|
||||
val newOrientation = OrientationType.fromPreference(orientation)
|
||||
if (newOrientation.flag != requestedOrientation) {
|
||||
requestedOrientation = newOrientation.flag
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
|
||||
* From 1 to 100 it sets that value as brightness.
|
||||
* 0 sets system brightness and hides the overlay.
|
||||
*/
|
||||
fun Activity.setCustomBrightnessValue(value: Int) {
|
||||
val readerBrightness = when {
|
||||
value > 0 -> value / 100f
|
||||
value < 0 -> 0.01f
|
||||
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun Alignment.Companion.fromPreferences(value: Int, isRtl: Boolean, isVertical: Boolean) = when (value) {
|
||||
1 -> when {
|
||||
isVertical -> CenterHorizontally
|
||||
isRtl -> AbsoluteAlignment.Right
|
||||
else -> AbsoluteAlignment.Left
|
||||
}
|
||||
2 -> AbsoluteAlignment.Left
|
||||
3 -> AbsoluteAlignment.Right
|
||||
else -> CenterHorizontally
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun ContentScale.Companion.fromPreferences(value: Int, srcSize: Size, dstSize: Size) = when (value) {
|
||||
2 -> Crop
|
||||
3 -> FillWidth
|
||||
4 -> FillHeight
|
||||
5 -> FixedScale(1 / Inside.computeScaleFactor(srcSize, dstSize).scaleX)
|
||||
6 -> if (srcSize.width > srcSize.height) FillHeight else FillWidth
|
||||
else -> Fit
|
||||
}
|
||||
|
||||
suspend fun PagerState.performScrollToPage(page: Int) {
|
||||
if (Settings.pageTransitions.value) {
|
||||
animateScrollToPage(page)
|
||||
} else {
|
||||
scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PagerState.moveToPrevious() {
|
||||
val target = currentPage - 1
|
||||
if (target >= 0) {
|
||||
performScrollToPage(target)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PagerState.moveToNext() {
|
||||
val target = currentPage + 1
|
||||
if (target < pageCount) {
|
||||
performScrollToPage(target)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun LazyListState.performScrollBy(value: Float) {
|
||||
if (Settings.pageTransitions.value) {
|
||||
animateScrollBy(value)
|
||||
} else {
|
||||
scrollBy(value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun LazyListState.scrollUp() {
|
||||
performScrollBy(-scrollDistance)
|
||||
}
|
||||
|
||||
suspend fun LazyListState.scrollDown() {
|
||||
performScrollBy(scrollDistance)
|
||||
}
|
||||
|
||||
private val LazyListState.scrollDistance
|
||||
get() = layoutInfo.viewportSize.height * 0.75f
|
||||
@@ -8,12 +8,15 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
context(CoroutineScope)
|
||||
@Composable
|
||||
fun VolumeKeysHandler(
|
||||
enabled: () -> Boolean,
|
||||
movePrevious: () -> Unit,
|
||||
moveNext: () -> Unit,
|
||||
movePrevious: suspend () -> Unit,
|
||||
moveNext: suspend () -> Unit,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
@@ -24,10 +27,12 @@ fun VolumeKeysHandler(
|
||||
val interval = Settings.readWithVolumeKeysInterval.value + 1
|
||||
val inverted = Settings.readWithVolumeKeysInverted.value
|
||||
if (event.action == ACTION_DOWN && event.repeatCount % interval == 0) {
|
||||
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
|
||||
movePrevious()
|
||||
} else {
|
||||
moveNext()
|
||||
launch {
|
||||
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
|
||||
movePrevious()
|
||||
} else {
|
||||
moveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
|
||||
import kotlinx.coroutines.launch
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun WebtoonViewer(
|
||||
lazyListState: LazyListState,
|
||||
withGaps: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val items = pageLoader.pages
|
||||
val zoomableState = rememberZoomableState(zoomSpec = WebtoonZoomSpec)
|
||||
val density = LocalDensity.current
|
||||
val paddingPercent by Settings.webtoonSidePadding.collectAsState()
|
||||
val sidePadding by remember(density) {
|
||||
snapshotFlow {
|
||||
with(density) {
|
||||
(lazyListState.layoutInfo.viewportSize.width * paddingPercent / 100f).toDp()
|
||||
}
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
LazyColumn(
|
||||
modifier = modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { offset ->
|
||||
scope.launch {
|
||||
with(lazyListState) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { ofs ->
|
||||
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
|
||||
info.offset <= ofs.y && info.offset + info.size > ofs.y
|
||||
}
|
||||
if (info != null) {
|
||||
onSelectPage(items[info.index])
|
||||
}
|
||||
},
|
||||
onDoubleClick = DoubleTapZoom,
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(if (withGaps) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val WebtoonZoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -19,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
|
||||
@@ -105,7 +105,8 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
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.Deferred
|
||||
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
|
||||
@@ -131,7 +132,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var gridView by Settings.gridView.asMutableState()
|
||||
var sortMode by Settings.downloadSortMode.asMutableState()
|
||||
val filterMode by Settings.downloadFilterMode.collectAsState { DownloadsFilterMode.from(it) }
|
||||
@@ -150,7 +151,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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)
|
||||
@@ -164,7 +168,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
when (label) {
|
||||
"" -> allName
|
||||
null -> emptyLabelName
|
||||
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
|
||||
else -> if (mode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -214,7 +218,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun closeSheet() = launch { drawerState.close() }
|
||||
TopAppBar(
|
||||
title = { Text(text = labelsStr) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
actions = {
|
||||
if (DownloadsFilterMode.CUSTOM == filterMode) {
|
||||
IconButton(
|
||||
@@ -295,7 +299,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = labelsListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
@@ -336,7 +340,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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 {
|
||||
@@ -351,7 +355,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { isDragging ->
|
||||
ReorderableItem(
|
||||
reorderableLabelState,
|
||||
id,
|
||||
enabled = editEnable,
|
||||
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { isDragging ->
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
@@ -374,7 +383,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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 {
|
||||
@@ -480,7 +489,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
onClick = {
|
||||
expanded = false
|
||||
launchIO {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = android.R.string.ok,
|
||||
dismissText = android.R.string.cancel,
|
||||
) {
|
||||
@@ -599,7 +608,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
|
||||
Deferred({ delay(200) }) {
|
||||
Await({ delay(200) }) {
|
||||
if (list.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(realPadding).fillMaxSize(),
|
||||
@@ -642,7 +651,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
|
||||
import android.content.Context
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.collection.MutableLongSet
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -73,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
|
||||
@@ -94,7 +96,7 @@ import moe.tarsin.coroutines.runSwallowingWithUI
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Immutables
|
||||
val localFavName = stringResource(R.string.local_favorites)
|
||||
val cloudFavName = stringResource(R.string.cloud_favorites)
|
||||
@@ -184,7 +186,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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) {
|
||||
@@ -355,8 +357,9 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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) {
|
||||
@@ -382,7 +385,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -59,6 +62,7 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -86,7 +90,6 @@ import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.normalizeSpan
|
||||
@@ -147,7 +150,7 @@ private val MinimumContentPaddingEditText = 88.dp
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
var commenting by rememberSaveable { mutableStateOf(false) }
|
||||
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
|
||||
@@ -208,7 +211,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
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)
|
||||
@@ -286,7 +289,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().imePadding(),
|
||||
modifier = Modifier.fillMaxSize().imePadding().padding(top = paddingValues.calculateTopPadding()),
|
||||
) {
|
||||
val additionalPadding = if (commenting) {
|
||||
editTextMeasured
|
||||
@@ -302,10 +305,15 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
val voteDownSucceed = stringResource(R.string.vote_down_successfully)
|
||||
val cancelVoteDownSucceed = stringResource(R.string.cancel_vote_down_successfully)
|
||||
val voteFailed = stringResource(R.string.vote_failed)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = keylineMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = paddingValues + PaddingValues(bottom = additionalPadding),
|
||||
contentPadding = PaddingValues(
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
|
||||
),
|
||||
) {
|
||||
items(
|
||||
items = comments.comments,
|
||||
|
||||
@@ -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,171 +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.foundation.MutatorMutex
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
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
|
||||
@@ -184,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 GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var galleryInfo by remember {
|
||||
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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) {
|
||||
@@ -243,15 +137,10 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
}
|
||||
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]
|
||||
@@ -277,20 +166,6 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -329,7 +204,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
val name = "$gid-${EhUtils.getSuitableTitle(galleryDetail)}.zip"
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
startActivity(intent)
|
||||
withUIContext { addTextToClipboard(name, true) }
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val r = DownloadManager.Request(uri)
|
||||
@@ -363,586 +238,6 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
|
||||
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() = 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) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
|
||||
)
|
||||
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) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -973,7 +268,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
AppHelper.share(activity, galleryDetailUrl)
|
||||
AppHelper.share(implicit<MainActivity>(), galleryDetailUrl)
|
||||
// In case the link is copied to the clipboard
|
||||
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
|
||||
},
|
||||
@@ -1033,7 +328,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
dropdown = false
|
||||
val gd = galleryInfo as? GalleryDetail ?: return@DropdownMenuItem
|
||||
launchIO {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
title = R.string.clear_image_cache,
|
||||
) {
|
||||
@@ -1064,7 +359,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
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)) },
|
||||
)
|
||||
@@ -1105,6 +400,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
GalleryDetailContent(
|
||||
galleryInfo = gi,
|
||||
contentPadding = it,
|
||||
getDetailError = getDetailError,
|
||||
onRetry = { getDetailError = "" },
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
)
|
||||
} else if (getDetailError.isNotBlank()) {
|
||||
@@ -1119,3 +416,18 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -22,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
|
||||
@@ -124,7 +124,9 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
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.Deferred
|
||||
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
|
||||
@@ -133,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.pickVisualMedia
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
@@ -155,23 +158,23 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
fun AnimatedVisibilityScope.HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
|
||||
fun AnimatedVisibilityScope.SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
|
||||
fun AnimatedVisibilityScope.WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
|
||||
fun AnimatedVisibilityScope.ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val searchFieldState = rememberTextFieldState()
|
||||
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
|
||||
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -281,7 +284,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
ProvideSideSheetContent { sheetState ->
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.toplist)) },
|
||||
windowInsets = WindowInsets(0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
)
|
||||
toplists.forEach { (name, keyword) ->
|
||||
ListItem(
|
||||
@@ -305,7 +308,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -362,7 +365,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets(0),
|
||||
windowInsets = EmptyWindowInsets,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val quickSearchListState = rememberLazyListState()
|
||||
@@ -372,21 +375,25 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = quickSearchListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
|
||||
val index by rememberUpdatedState(itemIndex)
|
||||
ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging ->
|
||||
ReorderableItem(
|
||||
reorderableLazyListState,
|
||||
item.id!!,
|
||||
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { isDragging ->
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
|
||||
LaunchedEffect(dismissState) {
|
||||
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 {
|
||||
@@ -467,7 +474,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
}
|
||||
}
|
||||
}
|
||||
Deferred({ delay(200) }) {
|
||||
Await({ delay(200) }) {
|
||||
if (quickSearchList.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.quick_search_tip),
|
||||
@@ -546,8 +553,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
fabHidden = it
|
||||
},
|
||||
title = suitableTitle,
|
||||
searchFieldState = searchFieldState,
|
||||
searchFieldHint = searchBarHint,
|
||||
searchFieldState = searchFieldState,
|
||||
suggestionProvider = {
|
||||
GalleryDetailUrlParser.parse(it, false)?.run {
|
||||
GalleryDetailUrlSuggestion(gid, token)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
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 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -52,7 +53,7 @@ import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
@@ -66,7 +67,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val title = stringResource(id = R.string.history)
|
||||
val hint = stringResource(R.string.search_bar_hint, title)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
@@ -101,7 +102,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
launch {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
|
||||
)
|
||||
@@ -164,7 +165,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Deferred(keyword, { delay(200) }) {
|
||||
Await(keyword, { delay(200) }) {
|
||||
if (historyData.itemCount == 0) {
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues).padding(horizontal = marginH).fillMaxSize(),
|
||||
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SearchBarHorizontalPadding
|
||||
import androidx.compose.material3.SearchBarInputField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -100,9 +99,9 @@ fun SearchBarScreen(
|
||||
onApplySearch: (String) -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
title: String? = null,
|
||||
title: String?,
|
||||
searchFieldHint: String,
|
||||
searchFieldState: TextFieldState = rememberTextFieldState(),
|
||||
searchFieldHint: String? = null,
|
||||
suggestionProvider: SuggestionProvider? = null,
|
||||
tagNamespace: Boolean = false,
|
||||
searchBarOffsetY: () -> Int = { 0 },
|
||||
@@ -190,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))
|
||||
@@ -226,14 +225,10 @@ fun SearchBarScreen(
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2),
|
||||
label = title.ifNotNullThen {
|
||||
Text(title!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
}.takeUnless {
|
||||
placeholder = {
|
||||
val contentActive by activeState.state
|
||||
expanded || contentActive || searchFieldState.text.isNotEmpty()
|
||||
},
|
||||
placeholder = searchFieldHint.ifNotNullThen {
|
||||
Text(searchFieldHint!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
val text = title.takeUnless { expanded || contentActive } ?: searchFieldHint
|
||||
Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1)
|
||||
},
|
||||
leadingIcon = {
|
||||
if (expanded) {
|
||||
@@ -318,3 +313,4 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (ke
|
||||
}
|
||||
|
||||
private val WhitespaceRegex = Regex("\\s+")
|
||||
private val SearchBarHorizontalPadding = 16.dp
|
||||
@@ -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(
|
||||
@@ -89,17 +94,31 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
|
||||
if (AppConfig.isSnapshot) {
|
||||
SwitchPreference(
|
||||
title = "New Compose Reader [WIP!!!]",
|
||||
value = Settings::newReader,
|
||||
)
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_advanced_save_parse_error_body),
|
||||
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),
|
||||
@@ -177,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 {
|
||||
@@ -215,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 {
|
||||
@@ -290,3 +300,5 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val AdsPlaceholderFile = appCtx.filesDir.toOkioPath() / "AdsPlaceholder"
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -63,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
|
||||
@@ -72,7 +72,7 @@ import splitties.init.appCtx
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
fun launchSnackBar(content: String) = launch { showSnackbar(content) }
|
||||
Scaffold(
|
||||
@@ -98,10 +98,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
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)
|
||||
@@ -259,7 +257,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
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(),
|
||||
@@ -333,11 +341,6 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
entryValueRes = R.array.thumb_resolution_entry_values,
|
||||
value = thumbResolution,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_eh_force_eh_thumb),
|
||||
summary = stringResource(id = R.string.settings_eh_force_eh_thumb_summary),
|
||||
value = Settings::forceEhThumb,
|
||||
)
|
||||
var showGalleryPages by Settings.showGalleryPages.asMutableState()
|
||||
SwitchPref(
|
||||
checked = showGalleryPages,
|
||||
|
||||
@@ -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
|
||||
@@ -58,7 +58,7 @@ import com.hippo.ehviewer.client.EhFilter.trigger
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -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,
|
||||
) {
|
||||
@@ -213,7 +213,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Deferred({ allFilterMap.await() }) { filters ->
|
||||
Await({ allFilterMap.await() }) { filters ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = paddingValues,
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
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.setValue
|
||||
import arrow.core.None
|
||||
import arrow.core.Option
|
||||
import arrow.core.some
|
||||
|
||||
@Composable
|
||||
fun <T> Await(
|
||||
block: suspend () -> T,
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
var completed by remember(block) { mutableStateOf<Option<T>>(None) }
|
||||
LaunchedEffect(block) {
|
||||
completed = block().some()
|
||||
}
|
||||
if (placeholder != null) {
|
||||
completed.onNone { placeholder() }
|
||||
}
|
||||
completed.onSome { content(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Await(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(key, block) { mutableStateOf<Option<T>>(None) }
|
||||
LaunchedEffect(key, block) {
|
||||
completed = block().some()
|
||||
}
|
||||
completed.onSome { content(it) }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
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.setValue
|
||||
|
||||
@Composable
|
||||
fun <T> Deferred(block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(block) { mutableStateOf<T?>(null) }
|
||||
LaunchedEffect(block) {
|
||||
completed = block()
|
||||
}
|
||||
completed?.let { content(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Deferred(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(key, block) { mutableStateOf<T?>(null) }
|
||||
LaunchedEffect(key, block) {
|
||||
completed = block()
|
||||
}
|
||||
completed?.let { content(it) }
|
||||
}
|
||||
@@ -50,12 +50,14 @@ import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -77,6 +79,7 @@ import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@@ -114,7 +117,6 @@ class DialogState {
|
||||
invalidator: (suspend Raise<String>.(R) -> Unit)? = null,
|
||||
block: @Composable DialogScope<R>.(String?) -> Unit,
|
||||
): R = dialog { cont ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = remember(cont) { mutableStateOf(initial) }
|
||||
var errorMsg by remember(cont) { mutableStateOf<String?>(null) }
|
||||
val impl = remember(cont) {
|
||||
@@ -122,17 +124,19 @@ class DialogState {
|
||||
override var expectedValue by state
|
||||
}
|
||||
}
|
||||
if (invalidator != null) {
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.value }.collectLatest {
|
||||
errorMsg = either { invalidator(it) }.leftOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (invalidator == null) {
|
||||
if (invalidator == null || errorMsg == null) {
|
||||
cont.resume(state.value)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
errorMsg = either { invalidator(state.value) }.leftOrNull()
|
||||
errorMsg ?: cont.resume(state.value)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(text = stringResource(id = android.R.string.ok))
|
||||
@@ -156,13 +160,17 @@ class DialogState {
|
||||
hint: String? = null,
|
||||
isNumber: Boolean = false,
|
||||
@StringRes confirmText: Int = android.R.string.ok,
|
||||
onUserDismiss: (() -> Unit)? = null,
|
||||
invalidator: (suspend Raise<String>.(String) -> Unit)? = null,
|
||||
) = dialog { cont ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var state by remember(cont) { mutableStateOf(initial) }
|
||||
var error by remember(cont) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
onDismissRequest = {
|
||||
cont.cancel()
|
||||
onUserDismiss?.invoke()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (invalidator == null) {
|
||||
@@ -264,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,
|
||||
@@ -451,7 +459,7 @@ class DialogState {
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
LazyColumn {
|
||||
FastScrollLazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
CheckableItem(
|
||||
text = text,
|
||||
@@ -487,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,
|
||||
@@ -522,7 +530,7 @@ class DialogState {
|
||||
leadingContent = {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -603,7 +611,7 @@ private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = M
|
||||
trailingContent = checked.ifTrueThen {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user