Compare commits

...
109 Commits
Author SHA1 Message Date
FooIbar 6191ad74d3 Prepare for release 1.12.0
Releases / build (push) Has been cancelled
2024-08-19 21:01:58 +08:00
FooIbar 5aae8f5649 Fix thumbnail resolution setting not working 2024-08-19 17:45:39 +08:00
FooIbar 6886465afb Refine reader
Fix navigation when switching from `WebtoonViewer` to `PagerViewer`.

Hide navigation overlay and reset slider to follow pager on click.
2024-08-19 17:45:26 +08:00
FooIbar 5a46fc4573 Don't recycle visible images 2024-08-19 13:49:36 +08:00
renovate[bot] a08b8e96e5 Lock file maintenance 2024-08-19 01:05:11 +00:00
FooIbar 5aeded7a24 Don't show load state indicator when no hits found 2024-08-18 17:40:48 +08:00
FooIbar 1883b82159 Fix pager unclickability when image is not loaded 2024-08-18 17:40:37 +08:00
FooIbar 302b57d4ac Fix normal preview in exhentai
Remove force e-hentai thumb option due to infeasible.
2024-08-17 00:52:44 +08:00
FooIbar c6b793923e Temporarily disable shared element transitions 2024-08-16 20:20:13 +08:00
FooIbar 058c280fd7 Avoid out-of-screen shared element transitions 2024-08-16 19:45:19 +08:00
renovate[bot] 0fa42d731b Update agp to v8.7.0-alpha07 2024-08-16 00:45:15 +00:00
FooIbar 9b54b57934 Refactor reader prefetch logic (#1523)
* Refactor reader prefetch logic

* Refine
2024-08-16 00:46:44 +08:00
FooIbar d3ab3ba61f [no ci] Update workflow 2024-08-15 18:29:41 +08:00
renovate[bot] 921f570151 Update dependency gradle to v8.10 2024-08-14 12:46:28 +00:00
FooIbar 03ab435401 Fix preload in reader (#1517)
* Fix preload in reader

* Reduce preload page count for ArchivePageLoader
2024-08-14 12:58:06 +08:00
renovate[bot] b73704ede1 Update dependency com.google.devtools.ksp to v2.0.20-RC2-1.0.24 2024-08-14 01:37:30 +00:00
renovate[bot] 97b2181ff7 Update agp to v8.7.0-alpha06 2024-08-13 19:11:16 +00:00
renovate[bot] ef5e3f7c51 Update Kotlin & KSP to v2.0.20-RC2 2024-08-13 13:41:34 +00:00
FooIbar 8a29d46b38 Revert^3 "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 22b5a175f4.
2024-08-13 18:54:38 +08:00
FooIbar 75d8ff4b64 Fix ComicInfo.xml for galleries with temp tags 2024-08-13 17:46:01 +08:00
FooIbar 33258ea554 Fix read progress insert crash when exiting reader 2024-08-13 17:40:21 +08:00
renovate[bot] a723b4ecd3 Lock file maintenance 2024-08-12 00:15:22 +00:00
renovate[bot] 78a237fb0e Update agp to v8.7.0-alpha05 2024-08-09 02:18:54 +00:00
renovate[bot] 8fe60c0163 Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha01 2024-08-08 06:19:26 +00:00
renovate[bot] ffe47b2ff2 Update dependency androidx.lifecycle:lifecycle-runtime-compose to v2.9.0-alpha01 2024-08-08 03:14:46 +00:00
renovate[bot] e05fffb41c Update dependency androidx.biometric:biometric-ktx to v1.4.0-alpha02 2024-08-08 03:14:00 +00:00
renovate[bot] 1fb5de2527 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha10 2024-08-08 00:40:20 +00:00
renovate[bot] 46790a1fbe Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-alpha02 2024-08-08 00:39:56 +00:00
renovate[bot] af1316f06b Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-alpha14 2024-08-07 22:11:39 +00:00
renovate[bot] 24564cac8d Update dependency androidx.paging:paging-compose to v3.3.2 2024-08-07 22:11:14 +00:00
renovate[bot] c4868f137b Update benchmark to v1.3.0-rc01 2024-08-07 20:38:52 +00:00
renovate[bot] 3ccfa0dd5b Update androidx.room to v2.7.0-alpha06 2024-08-07 20:38:26 +00:00
FooIbar fded0fd08d Fix GitHub authorization and clean up code 2024-08-07 18:37:51 +08:00
renovate[bot] b1afe9bcd1 Update agp to v8.7.0-alpha04 2024-08-06 22:54:54 +00:00
FooIbar 22b5a175f4 Reapply "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 94b138f254.
2024-08-06 23:01:20 +08:00
FooIbar ee9e0df12e [no ci] Fix release workflow 2024-08-06 22:03:30 +08:00
FooIbar dd525baf88 Use encoded token to avoid detection & revocation (#1492) 2024-08-06 21:33:46 +08:00
FooIbar 9180603ead Fix a division by zero error in ScrollbarAdapter 2024-08-06 21:30:12 +08:00
renovate[bot] 66fe19369e Lock file maintenance 2024-08-05 00:38:20 +00:00
renovate[bot] b9db025f57 Update gradle/actions action to v4 2024-08-04 00:54:28 +00:00
FooIbar 44f6eca3aa Fix main thread IO (really) and float comparison 2024-08-03 14:55:05 +08:00
FooIbar 1ff1caf421 Fix list item container color
Also compile debug build with SDK 35 as sources for SDK 35 have been
available.
2024-08-03 08:42:33 +08:00
github-actions[bot] ec1355c0e7 Update Chrome version for default User-Agent 2024-08-01 11:21:29 +08:00
FooIbar d23a453f71 Don't let navigation overlay consume click event
So it won't take two actions to dismiss overlay & app bar.
2024-08-01 07:25:47 +08:00
FooIbar 94b138f254 Revert "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit abcef9a86e.
2024-08-01 03:06:53 +08:00
FooIbar c281daa4ea Update NDK to r27 2024-08-01 01:42:33 +08:00
FooIbar 4f9eba8c63 Update Kotlin and remove workaround 2024-08-01 01:08:41 +08:00
FooIbar a2547f1e8d Update page range filter limit 2024-07-31 18:54:51 +08:00
FooIbar abcef9a86e Update Compose snapshot and migrate to BTF2 (#1474)
* Update Compose snapshot and remove workaround

* Sync SearchBarInputField with upstream

* Migrate to BTF2 text fields

* Fix search page range error text container color

* Update snapshot to fix a SieveCache crash

https://r.android.com/3199791
2024-07-31 17:40:09 +08:00
FooIbar 7d6a9b10e4 Fix crash when opening archives simultaneously 2024-07-30 16:27:16 +08:00
renovate[bot] 9322b80790 Update dependency com.google.devtools.ksp to v2.0.0-1.0.24 2024-07-30 00:51:18 +00:00
FooIbar e12897bd13 Fix layout issue with comments that contain images 2024-07-29 21:59:40 +08:00
FooIbar 28279cc671 Fix page transitions when reading with volume keys 2024-07-29 18:19:43 +08:00
FooIbar 2531fa289b Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha01 2024-07-29 10:47:13 +08:00
renovate[bot] 9673163e8f Lock file maintenance 2024-07-29 00:55:33 +00:00
renovate[bot] 3f7091f880 Update dependency sh.calvin.reorderable:reorderable to v2.3.0 2024-07-28 20:01:11 +00:00
FooIbar 5df75fd534 Don't save baseline profile in sources (#1462)
* Don't save baseline profile in sources

* Fixup
2024-07-28 17:25:36 +00:00
FooIbarandGG Bond 62061e54ee Add shared element transitions (#1049)
* Add shared element transitions

* Fixup CodeStyle

* Add a overload for `Modifier.sharedBounds`

* Add TransitionsVisibilityScope to hold multiple transition

* advance AnimatedVisibilityScope to TransitionsVisibilityScope

* composing use advance

* Correctly remember

* Convert to use `Set`

* share SharedContentState

* Refine

* Cleanup

* Cleanup

* Update snapshot

* Temporarily disable navigation transition

* Fix build

* Make `TransitionsVisibilityScope` a value class

* Naming

* Use `implicit` instead

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-07-28 14:11:47 +00:00
github-actions[bot] 436c07fb6b Update baseline profile 2024-07-28 21:34:06 +08:00
FooIbar a16f873f7d Fix crash when switching reading mode 2024-07-28 21:15:09 +08:00
FooIbar 98fdcf0588 Fix double free on closing failed-to-open archives 2024-07-28 20:48:56 +08:00
renovate[bot] 539ad1af30 Fix read encrypted archive 2024-07-28 12:05:04 +00:00
FooIbar 4370606977 [Reader] Show progress indicator & fix background 2024-07-28 19:45:18 +08:00
FooIbar 1ecdc07655 Graduate Compose Reader to stable 2024-07-28 17:29:50 +08:00
FooIbar 01a1113a07 [New Reader] Avoid initial scroll on reader start 2024-07-28 17:14:07 +08:00
renovate[bot] fd6c2977b0 Update plugin composeCompilerReportGenerator to v1.4.0 2024-07-28 08:50:51 +00:00
FooIbar 02e36e7021 [New Reader] Implement open archive intent 2024-07-28 14:00:23 +08:00
FooIbar 7f8e4aa003 [New Reader] Hide menu on scroll 2024-07-28 14:00:23 +08:00
FooIbar 8ce16bc918 [New Reader] Implement navigation overlay (#1445)
* [New Reader] Implement navigation overlay

* Fix typo

* Refine

* Reuse navigation regions
2024-07-26 23:45:41 +08:00
renovate[bot] fbe35a0fa0 Update agp to v8.7.0-alpha03 2024-07-25 23:43:05 +00:00
renovate[bot] 931f1ec514 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha02 2024-07-25 17:31:31 +00:00
renovate[bot] 7c4a59e9a4 Update compose.destinations to v2.1.0-beta11 2024-07-25 13:33:39 +00:00
FooIbar c1c6f25763 [New Reader] Refine landscape zoom (#1442) 2024-07-25 18:53:16 +08:00
renovate[bot] e7f515c70f Update dependency androidx.paging:paging-compose to v3.3.1 2024-07-24 19:48:04 +00:00
renovate[bot] 64fecbfa52 Update dependency androidx.lifecycle:lifecycle-process to v2.8.4 2024-07-24 17:18:31 +00:00
renovate[bot] c6c181a9fa Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha09 2024-07-24 22:05:22 +08:00
FooIbar 53a1bd9959 [New Reader] Implement landscape zoom 2024-07-24 20:36:43 +08:00
FooIbar 7b1f94cf5a [New Reader] Implement custom image scale types 2024-07-24 09:02:45 +08:00
renovate[bot] 4b8ecbf6a7 Update agp to v8.7.0-alpha02 2024-07-22 21:19:13 +00:00
renovate[bot] 0e0b0519e7 Lock file maintenance 2024-07-22 02:12:22 +00:00
FooIbar 7fb3b7c785 Tweak baseline profile generation schedule
[no ci]
2024-07-21 15:00:14 +08:00
renovate[bot]andFooIbar e18ebb1feb Update compose.destinations to v2.1.0-beta10 (#1435)
* Update compose.destinations to v2.1.0-beta10

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-07-21 06:30:57 +00:00
FooIbar f76dcfc97b [New Reader] Misc improvements 2024-07-21 08:30:32 +08:00
renovate[bot] c0c630bd9f Update dependency me.saket.telephoto:zoomable to v0.12.1 2024-07-20 09:00:29 +00:00
FooIbar 04df7da5dc Remove obsolete workaround 2024-07-17 17:38:01 +08:00
FooIbar d156638a79 Fix comment screen refresh indicator padding 2024-07-17 17:33:44 +08:00
FooIbar c78fe0b72c [New Reader] Implement touch navigation 2024-07-17 17:32:08 +08:00
renovate[bot] 799c37ea71 Update dependency sh.calvin.reorderable:reorderable to v2.2.0 2024-07-17 05:42:34 +00:00
renovate[bot] 9559ca571b Update agp 2024-07-15 19:40:24 +00:00
renovate[bot] 2f5c1e11c6 Lock file maintenance 2024-07-15 01:07:35 +00:00
renovate[bot] 734c73fa4d Update dependency me.saket.telephoto:zoomable to v0.12.0 2024-07-13 19:51:02 +00:00
renovate[bot] c09173d62c Update dependency com.google.devtools.ksp to v2.0.0-1.0.23 2024-07-11 21:35:55 +00:00
renovate[bot] 533f50ed08 Update dependency gradle to v8.9 2024-07-11 18:03:51 +00:00
Dude so hot b4422fe910 Drop MAP_UNINITIALIZED
This is not available on modern MMU devices
2024-07-11 21:31:34 +08:00
Dude so hot 22e58a034f Fix memleak while do archive 2024-07-11 21:12:31 +08:00
FooIbar 784856d341 Revert "Update agp to v8.6.0-beta01"
This reverts commit b982705b2f.
2024-07-11 08:45:00 +08:00
renovate[bot] f82832ca36 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha01 2024-07-11 00:15:30 +00:00
renovate[bot] 6a70aa792e Update benchmark to v1.3.0-beta02 2024-07-10 19:10:50 +00:00
renovate[bot] a0016dbf85 Update androidx.room to v2.7.0-alpha05 2024-07-10 19:10:30 +00:00
renovate[bot] b982705b2f Update agp to v8.6.0-beta01 2024-07-10 17:01:40 +00:00
FooIbar 4c36398bed Prepare for next iteration 2024-07-10 22:13:25 +08:00
FooIbar 7e1b3abcf6 Prepare for release 1.11.7
Releases / build (push) Has been cancelled
2024-07-10 22:12:56 +08:00
FooIbar 97bb65aa35 Fix search bar title flickering 2024-07-10 22:11:46 +08:00
FooIbar 98c1a377ca Fix main thread disk IO 2024-07-10 20:25:52 +08:00
FooIbar 2d2ce3281e Fix Path -> Uri conversion for paths contain '%'
This reverts commit ab2e4a8e2d.
2024-07-10 19:44:13 +08:00
renovate[bot] a28d3ba840 Update dependency org.jsoup:jsoup to v1.18.1 2024-07-10 08:41:55 +00:00
FooIbar ab2e4a8e2d Fix Path -> Uri conversion
No need to do it for '?' as that's not allowed in filename.
2024-07-10 12:59:29 +08:00
FooIbar 67ddba87b5 Fix workflow config
[no ci]
2024-07-09 22:29:28 +08:00
FooIbar 8164847fcb Prepare for next iteration 2024-07-09 22:03:40 +08:00
143 changed files with 1650 additions and 86707 deletions

No files matched your search

-82
View File
@@ -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
+3 -18
View File
@@ -38,13 +38,10 @@ jobs:
run: cargo clippy --all-targets --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
+20 -7
View File
@@ -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: |
+39 -1
View File
@@ -1,6 +1,44 @@
# 变更日志
## [1.11.6] - 2024-06-24
## [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]
### 改进
-2
View File
@@ -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
+9 -19
View File
@@ -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,9 +21,9 @@ 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"
ndkVersion = "27.0.12077973"
androidResources.generateLocaleConfig = true
splits {
@@ -64,15 +64,12 @@ 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 = 180059
versionName = "1.12.0"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
@@ -95,7 +92,6 @@ android {
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)
@@ -169,15 +165,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 +217,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 +236,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)
@@ -291,10 +279,12 @@ kotlin {
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-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",
-5
View File
@@ -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
+14 -20
View File
@@ -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"
+38 -32
View File
@@ -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;
}
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
@@ -30,7 +30,7 @@ import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.network.ktor2.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
@@ -142,7 +142,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)
@@ -175,9 +174,7 @@ object Settings : DataStorePreferences(null) {
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 +206,8 @@ 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)
init {
if ("CN" == Locale.getDefault().country) {
@@ -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,11 +56,12 @@ 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 java.io.File
@@ -96,7 +98,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 +126,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 +151,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 +163,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()
}
}
@@ -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,7 +46,11 @@ 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)
@@ -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"
@@ -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
@@ -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()
}
@@ -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,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
@@ -41,7 +42,7 @@ 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 withContext(req.interceptorCoroutineContext) { chain.proceed() }
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
@@ -35,6 +35,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
@@ -54,21 +55,19 @@ class ArchivePageLoader(
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
@@ -136,8 +135,8 @@ 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
@@ -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"
@@ -105,7 +105,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)
}
}
@@ -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,7 +18,6 @@
package com.hippo.ehviewer.image
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import androidx.compose.ui.unit.IntRect
import arrow.core.Either
import arrow.core.left
@@ -51,7 +50,7 @@ import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val size = image.size
val rect = when (image) {
is BitmapImageWithRect -> image.rect
@@ -63,14 +62,12 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
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
@@ -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,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)
@@ -50,6 +50,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 +225,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 +236,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun awaitReady(): Boolean {
prepareJob.join()
prepareJob.await()
return isReady
}
@@ -540,16 +541,16 @@ 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 {
if (!force && index in mSpiderDen) {
return@runCatching updatePageState(index, STATE_FINISHED)
}
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
@@ -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(),
)
}
}
@@ -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,8 @@ import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -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),
@@ -265,10 +269,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 +463,16 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
DestinationsNavHost(
navGraph = NavGraphs.root,
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
}
}
}
}
@@ -537,6 +554,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) {
@@ -8,7 +8,6 @@ import androidx.compose.animation.ExitTransition
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,18 +18,6 @@ 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 {
ProvideVectorPainterCache {
@@ -45,15 +32,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
}
},
)
}
}
}
@@ -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,7 @@ 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.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
@@ -90,6 +92,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun DownloadCard(
onClick: () -> Unit,
@@ -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,7 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.shouldCrop
@Composable
@@ -23,6 +27,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
return remember(model) { context.imageRequest(model) }
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
@NonRestartableComposable
fun EhAsyncThumb(
@@ -33,13 +38,17 @@ fun EhAsyncThumb(
) = AsyncImage(
model = requestOf(model),
contentDescription = null,
modifier = modifier,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .sharedBounds(key = "${model.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = onSuccess?.let { callback ->
{ callback(it.result.image) }
},
contentScale = contentScale,
)
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun EhAsyncCropThumb(
key: GalleryInfo,
@@ -49,7 +58,10 @@ fun EhAsyncCropThumb(
AsyncImage(
model = requestOf(key),
contentDescription = null,
modifier = modifier,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .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,7 @@ 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
@Composable
fun GalleryDetailHeaderInfoCard(
@@ -96,6 +97,7 @@ fun GalleryDetailHeaderInfoCard(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryDetailHeaderCard(
info: GalleryInfo,
@@ -109,7 +111,10 @@ fun GalleryDetailHeaderCard(
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)),
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))
@@ -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,7 @@ 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.util.FavouriteStatusRouter
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
@@ -88,6 +90,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoListItem(
onClick: () -> Unit,
@@ -199,6 +202,7 @@ fun GalleryInfoListItem(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoGridItem(
onClick: () -> Unit,
@@ -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,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,27 @@
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.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.unit.dp
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
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
@Composable
fun GalleryPager(
@@ -37,119 +29,57 @@ fun GalleryPager(
pagerState: PagerState,
lazyListState: LazyListState,
pageLoader: PageLoader2,
showNavigationOverlay: Boolean,
onNavigationModeChange: () -> Unit,
onSelectPage: (ReaderPage) -> 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.invertMode = TappingInvertMode.entries[invertMode]
navigation.regions
}
val navigator by rememberUpdatedState(regions)
if (isPagerType) {
PagerViewer(
pagerState = pagerState,
isRtl = type == RIGHT_TO_LEFT,
isVertical = type == VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
)
} else {
WebtoonViewer(
lazyListState = lazyListState,
withGaps = type == CONTINUOUS_VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
)
}
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
}
private val zoomSpec = ZoomSpec(maxZoomFactor = 3f)
@@ -0,0 +1,65 @@
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.rectF
val color = colorResource(region.type.colorRes)
val text = stringResource(region.type.nameRes)
Box(
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
contentAlignment = Alignment.Center,
) {
Text(text, style = strokeStyle)
Text(text, style = style)
}
}
}
}
}
private val AnimationSpec = tween<Float>(1000)
@@ -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
@@ -24,39 +25,37 @@ 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 coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image
import com.google.accompanist.drawablepainter.DrawablePainter
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
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 moe.tarsin.kt.unreachable
@Composable
fun PagerItem(
page: ReaderPage,
pageLoader: PageLoader,
zoomableState: ZoomableState,
webtoon: Boolean,
contentScale: ContentScale,
modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
pageLoader.request(page.index)
}
val defaultError = stringResource(id = R.string.decode_image_error)
val state by page.status.collectAsState()
when (state) {
Page.State.QUEUE, Page.State.LOAD_PAGE, Page.State.DOWNLOAD_IMAGE -> {
LaunchedEffect(Unit) {
pageLoader.request(page.index)
}
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
contentAlignment = Alignment.Center,
) {
val progress by page.progressFlow.collectAsState()
@@ -64,16 +63,25 @@ fun PagerItem(
}
}
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
val image = page.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,16 +90,9 @@ fun PagerItem(
else -> null
},
)
if (!webtoon) {
val size = painter.intrinsicSize
LaunchedEffect(size) {
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
zoomableState.setContentLocation(contentLocation)
}
}
}
Page.State.ERROR -> {
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Box(modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -111,13 +112,13 @@ fun PagerItem(
}
}
private fun Image.toPainter(rect: IntRect) = when (this) {
private fun Image.toPainter() = when (val image = innerImage) {
is BitmapImage -> BitmapPainter(
image = bitmap.asImageBitmap(),
image = image.bitmap.asImageBitmap(),
srcOffset = rect.topLeft,
srcSize = rect.size,
)
is DrawableImage -> DrawablePainter(drawable)
is DrawableImage -> DrawablePainter(image.drawable)
else -> unreachable()
}
@@ -0,0 +1,268 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
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.PageLoader2
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
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: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
contentPadding: PaddingValues,
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,
contentPadding = contentPadding,
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,
contentPadding = contentPadding,
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: ReaderPage,
pageLoader: PageLoader2,
isRtl: Boolean,
scaleType: Int,
landscapeZoom: Boolean,
alignment: Alignment.Horizontal,
layoutSize: Size,
navigator: () -> NavigationRegions,
pagerState: PagerState,
onSelectPage: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
scope: CoroutineScope,
) {
@Suppress("NAME_SHADOWING")
val isRtl by rememberUpdatedState(isRtl)
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
val status by page.status.collectAsState()
if (status == Page.State.READY && layoutSize != Size.Zero) {
val size = page.image!!.rect.size.toSize()
val 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 x = offset.x / size.width
val y = offset.y / size.height
val distance = size.width
val bounds = size.toRect()
when (navigator().getAction(x, y)) {
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,7 +1,13 @@
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
@@ -18,9 +24,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,17 +36,23 @@ 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.res.stringResource
import androidx.compose.ui.unit.dp
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
@@ -48,8 +62,12 @@ 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.PageLoader2
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.DialogState
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 +77,76 @@ 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.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
}
@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)
Await(
{
Either.catch {
readingFailed.takeUnless { pageLoader.awaitReady() }
}.fold({ it.displayString() }, { it })
},
placeholder = {
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
},
) { error ->
if (error == null) {
val info = (args as? ReaderScreenArgs.Gallery)?.info
key(pageLoader) {
ReaderScreen(pageLoader, info, navigator)
}
} else {
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
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) {
Settings.cropBorder.changesFlow().collect {
pageLoader.restart()
}
}
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
@@ -95,132 +162,181 @@ 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 = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
moveNext = { syncState.sliderScrollTo(syncState.sliderValue + 1) },
)
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() } } },
)
}
}
}
}
},
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor),
contentPadding = if (fullscreen) {
if (cutoutShort) {
PaddingValues(0.dp)
} else {
WindowInsets.displayCutout.asPaddingValues()
}
} else {
WindowInsets.systemBars.asPaddingValues()
},
)
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(),
)
}
}
ReaderAppBars(
visible = appbarVisible,
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
showSeekBar = showSeekbar,
currentPage = syncState.sliderValue,
totalPages = pageLoader.size,
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
onClickSettings = {
}
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)
}
GalleryPager(
type = readingMode,
pagerState = pagerState,
lazyListState = lazyListState,
pageLoader = pageLoader,
showNavigationOverlay = showNavigationOverlay,
onNavigationModeChange = { showNavigationOverlay = true },
onSelectPage = { page ->
if (Settings.readerLongTapAction.value) {
launch {
dialog { cont ->
fun dispose() = cont.resume(Unit)
var isColorFilter by remember { mutableStateOf(false) }
val scrim by animateColorAsState(
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
label = "ScrimColor",
)
val state = rememberModalBottomSheetState()
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,
sheetState = state,
contentWindowInsets = { WindowInsets(0) },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
appbarVisible = !isColorFilter
}
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() } } },
)
}
}
}
},
)
}
},
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor).pointerInput(syncState) {
awaitEachGesture {
waitForUpOrCancellation()
syncState.reset()
showNavigationOverlay = false
}
},
contentPadding = if (fullscreen) {
if (cutoutShort) {
PaddingValues(0.dp)
} else {
WindowInsets.displayCutout.asPaddingValues()
}
} else {
WindowInsets.systemBars.asPaddingValues()
},
)
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,
)
if (brightness) {
LaunchedEffect(Unit) {
Settings.customBrightnessValue.valueFlow().sample(100)
.onCompletion { setCustomBrightnessValue(0) }
.collect { setCustomBrightnessValue(it) }
}
}
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 = { WindowInsets(0) },
) {
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)
} 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,98 @@
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.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.PageLoader2
import com.hippo.ehviewer.ui.main.plus
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
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: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
contentPadding: PaddingValues,
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 = {
scope.launch {
with(lazyListState) {
val size = layoutInfo.viewportSize
val x = it.x / size.width
val y = it.y / size.height
when (navigator().getAction(x, y)) {
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 && withGaps) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
onDoubleClick = DoubleTapZoom,
),
state = lazyListState,
contentPadding = 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
@@ -105,7 +106,7 @@ 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.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) }
@@ -351,7 +352,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 = {},
@@ -599,7 +605,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
Deferred({ delay(200) }) {
Await({ delay(200) }) {
if (list.isEmpty()) {
Column(
modifier = Modifier.padding(realPadding).fillMaxSize(),
@@ -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
@@ -94,7 +95,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)
@@ -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 }
@@ -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,
@@ -10,6 +10,7 @@ import android.os.Parcelable
import android.text.TextUtils.TruncateAt.END
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -212,7 +213,7 @@ private fun List<GalleryTagGroup>.getArtistTag(): String? {
@Destination<RootGraph>
@Composable
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
var galleryInfo by remember {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
@@ -769,7 +770,11 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
else -> error("Invalid DownloadState!!!")
}
fun onReadButtonClick() = navToReader(galleryInfo.findBaseInfo(), startPage)
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) {
@@ -837,17 +842,19 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
)
}
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
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,
@@ -889,39 +896,39 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
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(),
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,
) {
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),
) {
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)
}
Text(text = readButtonText, maxLines = 1)
}
Spacer(modifier = modifier.height(24.dp))
FilledTonalButton(
onClick = onDownloadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = downloadButtonText, maxLines = 1)
}
}
}
}
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else if (galleryDetail != null) {
@@ -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
@@ -124,7 +125,7 @@ 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.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
@@ -133,6 +134,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 +157,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) }
@@ -379,7 +381,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
) {
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) {
@@ -467,7 +473,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 +552,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,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.padding
import androidx.compose.foundation.lazy.grid.GridCells
@@ -57,7 +58,7 @@ import moe.tarsin.coroutines.runSuspendCatching
@Destination<RootGraph>
@Composable
fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
val pgSize = detail.previewList.size
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
@@ -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()
@@ -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 },
@@ -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
@@ -89,12 +89,6 @@ 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),
@@ -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
@@ -72,7 +73,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(
@@ -333,11 +333,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,
@@ -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
@@ -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,
@@ -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) {
@@ -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,6 +1,41 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
if (condition) block() else this
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
@JvmInline
@Stable
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
context(TransitionsVisibilityScope)
@Composable
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun Modifier.sharedBounds(
key: String,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
) = scopes.fold(this) { modifier, scope ->
modifier.sharedBounds(
rememberSharedContentState("$key + ${scope.transition.label}"),
scope,
enter,
exit,
)
}
@@ -6,10 +6,10 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.executeAndParseAs
import com.hippo.ehviewer.client.executeSafely
import com.hippo.ehviewer.util.copyTo
import com.hippo.ehviewer.util.ensureSuccess
import io.ktor.client.request.header
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.utils.io.jvm.javaio.toInputStream
import java.io.File
import java.util.zip.ZipInputStream
@@ -17,10 +17,10 @@ import kotlin.time.Duration.Companion.days
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import moe.tarsin.coroutines.runSuspendCatching
import org.json.JSONObject
import tachiyomi.data.release.GithubArtifacts
import tachiyomi.data.release.GithubCommitComparison
import tachiyomi.data.release.GithubRelease
import tachiyomi.data.release.GithubRepo
import tachiyomi.data.release.GithubWorkflowRuns
private const val API_URL = "https://api.github.com/repos/${BuildConfig.REPO_NAME}"
@@ -35,7 +35,7 @@ object AppUpdater {
Settings.lastUpdateTime = now.epochSeconds
if (Settings.useCIUpdateChannel) {
val curSha = BuildConfig.COMMIT_SHA
val branch = ghStatement(API_URL).executeSafely { JSONObject(it.bodyAsText()).getString("default_branch") }
val branch = ghStatement(API_URL).executeAndParseAs<GithubRepo>().defaultBranch
val workflowRunsUrl = "$API_URL/actions/workflows/ci.yml/runs?branch=$branch&event=push&status=success&per_page=1"
val workflowRun = ghStatement(workflowRunsUrl).executeAndParseAs<GithubWorkflowRuns>().workflowRuns[0]
val shortSha = workflowRun.headSha.take(7)
@@ -68,6 +68,7 @@ object AppUpdater {
suspend fun downloadUpdate(url: String, file: File) =
ghStatement(url).executeSafely { response ->
response.status.ensureSuccess()
if (url.endsWith("zip")) {
response.bodyAsChannel().toInputStream().use { stream ->
ZipInputStream(stream).use { zip ->
@@ -84,9 +85,16 @@ object AppUpdater {
}
private suspend inline fun ghStatement(url: String) = ktorClient.prepareGet(url) {
header("Authorization", "Bearer ${BuildConfig.GITHUB_TOKEN}")
header("Authorization", GithubTokenParts.joinToString("_", prefix = "Bearer "))
}
private val GithubTokenParts = arrayOf(
"github",
"pat",
"11A4H2ACI0XXTRsYkt1Mps",
"uJ5n8UbLv20j6Ue4k1x25Z7u63EaCdDNYooxSeI4GJaQYKXWDZEtTmDVwnh",
)
data class Release(
val version: String,
val changelog: String,
@@ -39,12 +39,6 @@ private fun getReadableStringInternal(e: Throwable) = when (e) {
is MalformedURLException -> appCtx.getString(R.string.error_invalid_url)
is SocketTimeoutException -> appCtx.getString(R.string.error_timeout)
is UnknownHostException -> appCtx.getString(R.string.error_unknown_host)
is StatusCodeException -> buildString {
append(appCtx.getString(R.string.error_bad_status_code, e.responseCode))
if (e.isIdentifiedResponseCode) {
append(", ").append(e.message)
}
}
is ProtocolException -> if (e.message!!.startsWith("Too many follow-up requests:")) {
appCtx.getString(R.string.error_redirection)
} else {
@@ -2,6 +2,8 @@ package com.hippo.ehviewer.util
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.utils.io.charsets.decode
import io.ktor.utils.io.core.ByteReadPacket
import java.nio.charset.CodingErrorAction
@@ -15,3 +17,7 @@ suspend fun HttpResponse.bodyAsUtf8Text(): String {
return decoder.decode(input)
}
fun HttpStatusCode.ensureSuccess() {
if (!isSuccess()) throw Exception(toString())
}
@@ -1,24 +1,3 @@
@file:Suppress("NOTHING_TO_INLINE")
package com.hippo.ehviewer.util
import kotlin.reflect.KMutableProperty0
import kotlin.reflect.KProperty
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)
interface Lazy<T> {
var value: T
}
fun <T> lazyMut(init: () -> KMutableProperty0<T>) = object : Lazy<T> {
private val lazy by lazy { init() }
override var value
get() = lazy.get()
set(value) = lazy.set(value)
}
inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
inline operator fun <T> Lazy<T>.setValue(thisRef: Any?, prop: KProperty<*>?, newValue: T) {
value = newValue
}
@@ -1,62 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.util
import android.util.SparseArray
import com.hippo.ehviewer.R
import splitties.init.appCtx
class StatusCodeException(val responseCode: Int) : Exception() {
override val message: String = ERROR_MESSAGE_ARRAY[responseCode, DEFAULT_ERROR_MESSAGE]
val isIdentifiedResponseCode: Boolean
get() = DEFAULT_ERROR_MESSAGE != message
override fun getLocalizedMessage(): String = message
companion object {
private val ERROR_MESSAGE_ARRAY = SparseArray<String>(24)
private const val DEFAULT_ERROR_MESSAGE = "Error response code"
init {
val resources = appCtx.resources
ERROR_MESSAGE_ARRAY.append(400, resources.getString(R.string.error_status_code_400))
ERROR_MESSAGE_ARRAY.append(401, resources.getString(R.string.error_status_code_401))
ERROR_MESSAGE_ARRAY.append(402, resources.getString(R.string.error_status_code_402))
ERROR_MESSAGE_ARRAY.append(403, resources.getString(R.string.error_status_code_403))
ERROR_MESSAGE_ARRAY.append(404, resources.getString(R.string.error_status_code_404))
ERROR_MESSAGE_ARRAY.append(405, resources.getString(R.string.error_status_code_405))
ERROR_MESSAGE_ARRAY.append(406, resources.getString(R.string.error_status_code_406))
ERROR_MESSAGE_ARRAY.append(407, resources.getString(R.string.error_status_code_407))
ERROR_MESSAGE_ARRAY.append(408, resources.getString(R.string.error_status_code_408))
ERROR_MESSAGE_ARRAY.append(409, resources.getString(R.string.error_status_code_409))
ERROR_MESSAGE_ARRAY.append(410, resources.getString(R.string.error_status_code_410))
ERROR_MESSAGE_ARRAY.append(411, resources.getString(R.string.error_status_code_411))
ERROR_MESSAGE_ARRAY.append(412, resources.getString(R.string.error_status_code_412))
ERROR_MESSAGE_ARRAY.append(413, resources.getString(R.string.error_status_code_413))
ERROR_MESSAGE_ARRAY.append(414, resources.getString(R.string.error_status_code_414))
ERROR_MESSAGE_ARRAY.append(415, resources.getString(R.string.error_status_code_415))
ERROR_MESSAGE_ARRAY.append(416, resources.getString(R.string.error_status_code_416))
ERROR_MESSAGE_ARRAY.append(417, resources.getString(R.string.error_status_code_417))
ERROR_MESSAGE_ARRAY.append(500, resources.getString(R.string.error_status_code_500))
ERROR_MESSAGE_ARRAY.append(501, resources.getString(R.string.error_status_code_501))
ERROR_MESSAGE_ARRAY.append(502, resources.getString(R.string.error_status_code_502))
ERROR_MESSAGE_ARRAY.append(503, resources.getString(R.string.error_status_code_503))
ERROR_MESSAGE_ARRAY.append(504, resources.getString(R.string.error_status_code_504))
ERROR_MESSAGE_ARRAY.append(505, resources.getString(R.string.error_status_code_505))
}
}
}
@@ -103,14 +103,14 @@ class AndroidFileSystem(context: Context) : FileSystem() {
}
}
val uri = dir.toUri()
var documentId = DocumentsContract.getDocumentId(uri)
if (uri.isCifsDocument()) {
documentId += '/'
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId)
return runCatching {
val uri = dir.toUri()
var documentId = DocumentsContract.getDocumentId(uri)
if (uri.isCifsDocument()) {
documentId += '/'
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId)
contentResolver.query(childrenUri, arrayOf(Document.COLUMN_DISPLAY_NAME), null, null, null)?.use { c ->
List(c.count) {
c.moveToNext()
@@ -126,15 +126,15 @@ class AndroidFileSystem(context: Context) : FileSystem() {
return physicalFileSystem.metadataOrNull(path)
}
val uri = path.toUri()
val isMediaUri = uri.authority == MediaStore.AUTHORITY
val projection = if (isMediaUri) {
arrayOf(MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.DATE_MODIFIED)
} else {
arrayOf(Document.COLUMN_MIME_TYPE, Document.COLUMN_LAST_MODIFIED)
}
return runCatching {
val uri = path.toUri()
val isMediaUri = uri.authority == MediaStore.AUTHORITY
val projection = if (isMediaUri) {
arrayOf(MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.DATE_MODIFIED)
} else {
arrayOf(Document.COLUMN_MIME_TYPE, Document.COLUMN_LAST_MODIFIED)
}
contentResolver.query(uri, projection, null, null, null)?.use { c ->
if (!c.moveToNext()) return null
+9 -5
View File
@@ -38,14 +38,18 @@ fun Path.toUri(): Uri {
}
val uri = str.replaceFirst("content:/", "content://").toUri()
val paths = uri.pathSegments
return if (paths.size > 3 && paths[0] == "tree") {
val path = requireNotNull(uri.encodedPath) { "Invalid path: $str" }
val paths = path.split('/').dropWhile { it.isEmpty() }
return if (paths.size > 4 && paths[0] == "tree") {
uri.buildUpon().apply {
path(null)
repeat(3) { i ->
appendPath(paths[i])
appendEncodedPath(paths[i])
}
appendPath(paths.subList(3, paths.size).joinToString("/").replaceFirst(":/", ":"))
val root = Uri.decode(paths[3])
val prefix = if (root.endsWith(':')) root else "$root/"
val suffix = uri.encodedFragment?.let { "#$it" }.orEmpty()
appendPath(paths.subList(4, paths.size).joinToString("/", prefix, suffix))
}.build()
} else {
uri
@@ -53,7 +57,7 @@ fun Path.toUri(): Uri {
}
fun Uri.toOkioPath() = if (scheme == ContentResolver.SCHEME_FILE) {
path!!
requireNotNull(path) { "Invalid URI: $this" }
} else {
toString()
}.toPath()
@@ -1,150 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import android.content.SharedPreferences
import android.content.SharedPreferences.Editor
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
sealed class AndroidPreference<T>(
private val preferences: SharedPreferences,
private val keyFlow: Flow<String?>,
private val key: String,
private val defaultValue: T,
) : Preference<T> {
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
abstract fun write(key: String, value: T): Editor.() -> Unit
override fun key(): String = key
override fun get(): T = read(preferences, key, defaultValue)
// Async, don't call get() immediately after this
override fun set(value: T) {
preferences.edit(action = write(key, value))
}
override fun isSet(): Boolean = preferences.contains(key)
override fun delete() {
preferences.edit {
remove(key)
}
}
override fun defaultValue(): T = defaultValue
override fun changes(): Flow<T> = keyFlow
.filter { it == key || it == null }
.onStart { emit("ignition") }
.map { get() }
.conflate()
override fun stateIn(scope: CoroutineScope): StateFlow<T> = changes().stateIn(scope, SharingStarted.Eagerly, get())
class StringPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: String,
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: String): String = preferences.getString(key, defaultValue) ?: defaultValue
override fun write(key: String, value: String): Editor.() -> Unit = {
putString(key, value)
}
}
class LongPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Long,
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long = preferences.getLong(key, defaultValue)
override fun write(key: String, value: Long): Editor.() -> Unit = {
putLong(key, value)
}
}
class IntPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Int,
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int = preferences.getInt(key, defaultValue)
override fun write(key: String, value: Int): Editor.() -> Unit = {
putInt(key, value)
}
}
class FloatPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Float,
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float = preferences.getFloat(key, defaultValue)
override fun write(key: String, value: Float): Editor.() -> Unit = {
putFloat(key, value)
}
}
class BooleanPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Boolean,
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Boolean): Boolean = preferences.getBoolean(key, defaultValue)
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
putBoolean(key, value)
}
}
class StringSetPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Set<String>,
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Set<String>): Set<String> = preferences.getStringSet(key, defaultValue) ?: defaultValue
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
putStringSet(key, value)
}
}
class Object<T>(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: T,
val serializer: (T) -> String,
val deserializer: (String) -> T,
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T = try {
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
} catch (e: Exception) {
defaultValue
}
override fun write(key: String, value: T): Editor.() -> Unit = {
putString(key, serializer(value))
}
}
}
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import android.content.SharedPreferences
import com.hippo.ehviewer.Settings
import eu.kanade.tachiyomi.core.preference.AndroidPreference.BooleanPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.FloatPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.IntPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.LongPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.Object
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringSetPrimitive
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
object AndroidPreferenceStore : PreferenceStore {
private val sharedPreferences = Settings.prefs
private val keyFlow = sharedPreferences.keyFlow
override fun getString(key: String, defaultValue: String): Preference<String> = StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getLong(key: String, defaultValue: Long): Preference<Long> = LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getInt(key: String, defaultValue: Int): Preference<Int> = IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getFloat(key: String, defaultValue: Float): Preference<Float> = FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> = BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> = StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun <T> getObject(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T> = Object(
preferences = sharedPreferences,
keyFlow = keyFlow,
key = key,
defaultValue = defaultValue,
serializer = serializer,
deserializer = deserializer,
)
}
private val SharedPreferences.keyFlow
get() = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> trySend(key) }
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
@@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface Preference<T> {
fun key(): String
fun get(): T
fun set(value: T)
fun isSet(): Boolean
fun delete()
fun defaultValue(): T
fun changes(): Flow<T>
fun stateIn(scope: CoroutineScope): StateFlow<T>
}
inline fun <reified T, R : T> Preference<T>.getAndSet(crossinline block: (T) -> R) = set(block(get()))
@@ -1,39 +0,0 @@
package eu.kanade.tachiyomi.core.preference
interface PreferenceStore {
fun getString(key: String, defaultValue: String = ""): Preference<String>
fun getLong(key: String, defaultValue: Long = 0): Preference<Long>
fun getInt(key: String, defaultValue: Int = 0): Preference<Int>
fun getFloat(key: String, defaultValue: Float = 0f): Preference<Float>
fun getBoolean(key: String, defaultValue: Boolean = false): Preference<Boolean>
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
fun <T> getObject(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T>
}
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
key: String,
defaultValue: T,
): Preference<T> = getObject(
key = key,
defaultValue = defaultValue,
serializer = { it.name },
deserializer = {
try {
enumValueOf(it)
} catch (e: IllegalArgumentException) {
defaultValue
}
},
)
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.data.preference
const val MANGA_NON_COMPLETED = "manga_ongoing"
const val MANGA_HAS_UNREAD = "manga_fully_read"
const val MANGA_NON_READ = "manga_started"
/**
* This class stores the values for the preferences in the application.
*/
object PreferenceValues {
enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) {
NONE,
HORIZONTAL(shouldInvertHorizontal = true),
VERTICAL(shouldInvertVertical = true),
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true),
}
enum class ReaderHideThreshold(val threshold: Int) {
HIGHEST(5),
HIGH(13),
LOW(31),
LOWEST(47),
}
}
@@ -26,9 +26,10 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import eu.kanade.tachiyomi.util.system.isTabletUi
import kotlin.math.roundToInt
@Composable
@@ -38,7 +39,8 @@ fun ChapterNavigator(
totalPages: Int,
onSliderValueChange: (Int) -> Unit,
) {
val isTabletUi = LocalConfiguration.current.isTabletUi()
val windowSizeClass = LocalWindowSizeClass.current
val isTabletUi = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
val horizontalPadding = if (isTabletUi) 24.dp else 16.dp
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
val configuration = LocalConfiguration.current
@@ -1,833 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.kanade.tachiyomi.ui.reader
import android.Manifest
import android.app.assist.AssistContent
import android.content.ClipData
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View.LAYER_TYPE_HARDWARE
import android.view.WindowManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.core.content.FileProvider
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import arrow.core.raise.ensure
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.databinding.ReaderActivityBinding
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.PageLoader2
import com.hippo.ehviewer.ui.EhActivity
import com.hippo.ehviewer.ui.reader.SettingsPager
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.setMD3Content
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getValue
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.lazyMut
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.setValue
import com.hippo.files.toOkioPath
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlin.coroutines.resume
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.systemservices.clipboardManager
class GalleryModel : ViewModel() {
var galleryProvider: PageLoader2? = null
override fun onCleared() {
super.onCleared()
galleryProvider?.stop()
galleryProvider = null
}
}
class ReaderActivity : EhActivity() {
lateinit var binding: ReaderActivityBinding
private var mAction: String? = null
private var mUri: Uri? = null
private var mGalleryInfo: BaseGalleryInfo? = null
private var mPage: Int = -1
private val vm: GalleryModel by viewModels()
private val dialogState = DialogState()
/**
* Whether the menu is currently visible.
*/
var menuVisible by mutableStateOf(false)
private var mGalleryProvider by lazyMut { vm::galleryProvider }
private var mCurrentIndex: Int
get() = mGalleryProvider!!.startPage
set(value) {
mGalleryProvider!!.startPage = value
}
private val galleryDetailUrl: String?
get() = mGalleryInfo?.run { EhUrl.getGalleryDetailUrl(gid, token) }
private suspend fun buildProvider(replace: Boolean = false): PageLoader2? {
mGalleryProvider?.let {
if (replace) it.stop() else return it
}
val provider = when (mAction) {
ACTION_EH -> {
mGalleryInfo?.let {
DownloadManager.getDownloadInfo(it.gid)?.archiveFile
?.let { file -> ArchivePageLoader(file, it.gid, mPage) }
?: EhPageLoader(it, mPage)
}
}
Intent.ACTION_VIEW -> {
mUri?.run {
ArchivePageLoader(toOkioPath()) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) { text ->
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
}
}.onFailure {
finish()
}.getOrThrow()
}
}
}
else -> null
}
if (provider == null) {
makeToast(R.string.error_reading_failed)
finish()
}
return provider
}
private fun handleIntent(intent: Intent?) {
intent ?: return
mAction = intent.action
mUri = intent.data
mGalleryInfo = intent.getParcelableExtraCompat(KEY_GALLERY_INFO)
mPage = intent.getIntExtra(KEY_PAGE, -1)
}
private fun onInit() {
handleIntent(intent)
}
private fun onRestore(savedInstanceState: Bundle) {
mAction = savedInstanceState.getString(KEY_ACTION)
mUri = savedInstanceState.getParcelableCompat(KEY_URI)
mGalleryInfo = savedInstanceState.getParcelableCompat(KEY_GALLERY_INFO)
mPage = savedInstanceState.getInt(KEY_PAGE, -1)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
lifecycleScope.launchIO {
buildProvider(true)?.let {
mGalleryProvider = it
it.start()
if (it.awaitReady()) {
withUIContext {
totalPage = it.size
viewer?.setGalleryProvider(it)
moveToPageIndex(0)
}
} else {
makeToast(R.string.error_reading_failed)
finish()
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_ACTION, mAction)
outState.putParcelable(KEY_URI, mUri)
if (mGalleryInfo != null) {
outState.putParcelable(KEY_GALLERY_INFO, mGalleryInfo)
}
outState.putInt(KEY_PAGE, mPage)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
onInit()
} else {
onRestore(savedInstanceState)
}
binding = ReaderActivityBinding.inflate(layoutInflater)
binding.dialogStub.setMD3Content {
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) {
1 -> BlendMode.Multiply
2 -> BlendMode.Screen
3 -> BlendMode.Overlay
4 -> BlendMode.Lighten
5 -> BlendMode.Darken
else -> BlendMode.SrcOver
}
}
dialogState.Intercept()
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val sliderValueFlow = remember { MutableStateFlow(-1) }
LaunchedEffect(sliderValueFlow) {
sliderValueFlow.drop(1).debounce(200).collect {
moveToPageIndex(it)
}
}
ReaderAppBars(
visible = menuVisible,
isRtl = isRtl,
showSeekBar = showSeekbar,
currentPage = currentPage,
totalPages = totalPage,
onSliderValueChange = {
isScrollingThroughPages = true
sliderValueFlow.value = it
currentPage = it + 1
},
onClickSettings = {
lifecycleScope.launch {
dialogState.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)),
dragHandle = null,
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
contentWindowInsets = { WindowInsets(0) },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
setMenuVisibility(!isColorFilter)
}
}
}
}
},
)
ReaderContentOverlay(
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
color = { colorOverlay }.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayMode,
)
}
setContentView(binding.root)
lifecycleScope.launchIO {
buildProvider()?.let {
mGalleryProvider = it
it.start()
if (it.awaitReady()) {
withUIContext { setGallery() }
} else {
makeToast(R.string.error_reading_failed)
finish()
}
}
}
config = ReaderConfig()
initializeMenu()
}
fun retryPage(index: Int, orgImg: Boolean = false) {
mGalleryProvider?.retryPage(index, orgImg)
}
fun restartGalleryProvider() {
mGalleryProvider?.let {
it.restart()
viewer?.refreshAdapter()
}
}
private fun setGallery() {
if (mGalleryProvider?.isReady != true) return
totalPage = mGalleryProvider!!.size
viewer?.destroy()
viewer = ReadingModeType.toViewer(ReaderPreferences.defaultReadingMode().get(), this)
isRtl = viewer!!.isRtl
updateViewerInsets(ReaderPreferences.cutoutShort().get())
binding.viewerContainer.removeAllViews()
setOrientation(ReaderPreferences.defaultOrientationType().get())
binding.viewerContainer.addView(viewer?.getView())
viewer?.setGalleryProvider(mGalleryProvider!!)
moveToPageIndex(mCurrentIndex)
}
override fun onDestroy() {
super.onDestroy()
config = null
viewer?.destroy()
viewer = null
}
private suspend fun makeToast(text: String) = withUIContext {
Toast.makeText(this@ReaderActivity, text, Toast.LENGTH_SHORT).show()
}
private suspend fun makeToast(@StringRes resId: Int) = makeToast(getString(resId))
private fun provideImage(index: Int): Uri? = AppConfig.externalTempDir?.let { dir ->
mGalleryProvider?.run {
getImageFilename(index)?.let { filename ->
val file = File(dir, filename).takeIf { save(index, it.toOkioPath()) } ?: return null
FileProvider.getUriForFile(implicit<Context>(), BuildConfig.APPLICATION_ID + ".fileprovider", file)
}
}
}
fun shareImage(index: Int) {
lifecycleScope.launchIO {
val uri = provideImage(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.putExtra(Intent.EXTRA_STREAM, uri)
mGalleryInfo?.run {
intent.putExtra(Intent.EXTRA_TEXT, EhUrl.getGalleryDetailUrl(gid, token))
}
val extension = FileUtils.getExtensionFromFilename(uri.path)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
intent.setDataAndType(uri, mimeType)
try {
startActivity(Intent.createChooser(intent, getString(R.string.share_image)))
} catch (e: Throwable) {
makeToast(R.string.error_cant_find_activity)
}
}
}
fun copyImage(index: Int) {
lifecycleScope.launchIO {
val uri = provideImage(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val clipData = ClipData.newUri(contentResolver, "ehviewer", uri)
clipboardManager.setPrimaryClip(clipData)
makeToast(R.string.copied_to_clipboard)
}
}
fun saveImage(index: Int) {
mGalleryProvider ?: return
lifecycleScope.launchIO {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
val filename = mGalleryProvider!!.getImageFilename(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
val realPath: String
val resolver = contentResolver
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
values.put(MediaStore.MediaColumns.DATE_ADDED, Clock.System.now().epochSeconds)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (isAtLeastQ) {
realPath = Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME
values.put(MediaStore.MediaColumns.RELATIVE_PATH, realPath)
values.put(MediaStore.MediaColumns.IS_PENDING, 1)
} else {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val path = File(dir, AppConfig.APP_DIRNAME)
realPath = path.toString()
if (!FileUtils.ensureDirectory(path)) {
return@launchIO makeToast(R.string.error_cant_save_image)
}
values.put(MediaStore.MediaColumns.DATA, realPath + File.separator + filename)
}
val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: return@launchIO makeToast(R.string.error_cant_save_image)
if (!mGalleryProvider!!.save(index, imageUri.toOkioPath())) {
try {
resolver.delete(imageUri, null, null)
} catch (e: Exception) {
logcat(e)
}
return@launchIO makeToast(R.string.error_cant_save_image)
} else if (isAtLeastQ) {
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(imageUri, contentValues, null, null)
}
makeToast(getString(R.string.image_saved, realPath + File.separator + filename))
} else {
makeToast(R.string.permission_denied)
}
}
}
fun saveImageTo(index: Int) {
lifecycleScope.launchIO {
val filename = mGalleryProvider?.getImageFilename(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
runSuspendCatching {
awaitActivityResult(CreateDocument(mimeType), filename)?.let {
mGalleryProvider!!.save(index, it.toOkioPath())
makeToast(getString(R.string.image_saved, it.displayPath))
}
}.onFailure {
logcat(it)
makeToast(R.string.error_cant_find_activity)
}
}
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
val url = galleryDetailUrl
if (url != null) {
outContent.webUri = Uri.parse(url)
}
}
companion object {
const val ACTION_EH = "eh"
const val KEY_ACTION = "action"
const val KEY_URI = "uri"
const val KEY_GALLERY_INFO = "gallery_info"
const val KEY_PAGE = "page"
}
// Tachiyomi funcs
var isScrollingThroughPages = false
private set
/**
* Viewer used to display the pages (pager, webtoon, ...).
*/
var viewer: BaseViewer? = null
private set
private var config: ReaderConfig? = null
private val windowInsetsController by lazy {
WindowInsetsControllerCompat(
window,
binding.root,
)
}
/**
* Sets the visibility of the menu according to [visible].
*/
private fun setMenuVisibility(visible: Boolean) {
menuVisible = visible
if (visible) {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
if (ReaderPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(false)
}
} else {
if (ReaderPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
if (ReaderPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(true)
}
}
}
private var currentPage by mutableIntStateOf(-1)
private var totalPage by mutableIntStateOf(-1)
private var isRtl by mutableStateOf(false)
/**
* Initializes the reader menu. It sets up click listeners and the initial visibility.
*/
private fun initializeMenu() {
binding.pageNumber.setMD3Content {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
) {
PageIndicatorText(
currentPage = currentPage,
totalPages = totalPage,
modifier = Modifier.navigationBarsPadding(),
)
}
}
// Set initial visibility
setMenuVisibility(menuVisible)
}
/**
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handled = viewer?.handleKeyEvent(event) ?: false
return handled || super.dispatchKeyEvent(event)
}
/**
* Dispatches a generic motion event. If the viewer doesn't handle it, call the default
* implementation.
*/
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
val handled = viewer?.handleGenericMotionEvent(event) ?: false
return handled || super.dispatchGenericMotionEvent(event)
}
/**
* Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
* page is not found.
*/
private fun moveToPageIndex(index: Int) {
val viewer = viewer ?: return
val page = mGalleryProvider?.pages?.getOrNull(index) ?: return
viewer.moveToPage(page)
}
/**
* Called from the viewer whenever a [page] is marked as active. It updates the values of the
* bottom menu and delegates the change to the presenter.
*/
fun onPageSelected(page: ReaderPage) {
// Set bottom page number
currentPage = page.number
mCurrentIndex = page.index
}
/**
* Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of
* actions to perform is shown.
*/
fun onPageLongTap(page: ReaderPage) {
lifecycleScope.launch {
dialogState.dialog {
ModalBottomSheet(
onDismissRequest = { it.cancel() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
contentWindowInsets = { WindowInsets(0) },
) {
ReaderPageSheet(page) { it.cancel() }
}
}
}
}
/**
* Called from the viewer to toggle the visibility of the menu. It's implemented on the
* viewer because each one implements its own touch and key events.
*/
fun toggleMenu() {
setMenuVisibility(!menuVisible)
}
/**
* Called from the viewer to hide the menu.
*/
fun hideMenu() {
if (menuVisible) {
setMenuVisibility(false)
}
}
/**
* Forces the user preferred [orientation] on the activity.
*/
private fun setOrientation(orientation: Int) {
val newOrientation = OrientationType.fromPreference(orientation)
if (newOrientation.flag != requestedOrientation) {
requestedOrientation = newOrientation.flag
}
}
/**
* Updates viewer insets depending on fullscreen reader preferences.
*/
fun updateViewerInsets(drawInCutout: Boolean) {
viewer?.getViewForInsets()?.applyInsetter {
type(navigationBars = true, statusBars = true, displayCutout = !drawInCutout) {
padding()
}
}
}
/**
* Class that handles the user preferences of the reader.
*/
private inner class ReaderConfig {
private fun getCombinedPaint(grayscale: Boolean, invertedColors: Boolean): Paint = Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix().apply {
if (grayscale) {
setSaturation(0f)
}
if (invertedColors) {
postConcat(
ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f,
),
),
)
}
},
)
}
/**
* Initializes the reader subscriptions.
*/
init {
ReaderPreferences.defaultReadingMode().changes()
.drop(1)
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.defaultOrientationType().changes()
.drop(1)
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.cropBorders().changes()
.onEach { restartGalleryProvider() }
.launchIn(lifecycleScope)
ReaderPreferences.readerTheme().changes()
.onEach { theme ->
binding.readerContainer.setBackgroundResource(
when (theme) {
0 -> android.R.color.white
2 -> R.color.reader_background_dark
3 -> automaticBackgroundColor()
else -> android.R.color.black
},
)
}
.launchIn(lifecycleScope)
ReaderPreferences.showPageNumber().changes()
.onEach { setPageNumberVisibility(it) }
.launchIn(lifecycleScope)
ReaderPreferences.cutoutShort().changes()
.onEach { updateViewerInsets(it) }
.launchIn(lifecycleScope)
ReaderPreferences.keepScreenOn().changes()
.onEach { setKeepScreenOn(it) }
.launchIn(lifecycleScope)
ReaderPreferences.customBrightness().changes()
.onEach { setCustomBrightness(it) }
.launchIn(lifecycleScope)
merge(
ReaderPreferences.grayscale().changes(),
ReaderPreferences.invertedColors().changes(),
)
.onEach {
setLayerPaint(
ReaderPreferences.grayscale().get(),
ReaderPreferences.invertedColors().get(),
)
}
.launchIn(lifecycleScope)
}
/**
* Picks background color for [ReaderActivity] based on light/dark theme preference
*/
private fun automaticBackgroundColor(): Int = if (baseContext.isNightMode()) {
R.color.reader_background_dark
} else {
android.R.color.white
}
/**
* Sets the visibility of the bottom page indicator according to [visible].
*/
fun setPageNumberVisibility(visible: Boolean) {
binding.pageNumber.isVisible = visible
}
/**
* Sets the keep screen on mode according to [enabled].
*/
private fun setKeepScreenOn(enabled: Boolean) {
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* Sets the custom brightness overlay according to [enabled].
*/
@OptIn(FlowPreview::class)
private fun setCustomBrightness(enabled: Boolean) {
if (enabled) {
ReaderPreferences.customBrightnessValue().changes()
.sample(100)
.onEach { setCustomBrightnessValue(it) }
.launchIn(lifecycleScope)
} else {
setCustomBrightnessValue(0)
}
}
/**
* 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.
*/
private fun setCustomBrightnessValue(value: Int) {
// Calculate and set reader brightness.
val readerBrightness = when {
value > 0 -> {
value / 100f
}
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
val paint = if (grayscale || invertedColors) {
getCombinedPaint(
grayscale,
invertedColors,
)
} else {
null
}
binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint)
}
}
}
@@ -1,112 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.graphics.withScale
import androidx.core.graphics.withTranslation
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import kotlin.math.abs
class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private var viewPropertyAnimator: ViewPropertyAnimator? = null
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
invalidate()
if (isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
viewPropertyAnimator = animate()
.alpha(1f)
.setDuration(FADE_DURATION)
.withStartAction {
isVisible = true
}
.withEndAction {
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
private val regionPaint = Paint()
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.WHITE
textSize = 64f
}
private val textBorderPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
textSize = 64f
style = Paint.Style.STROKE
strokeWidth = 8f
}
override fun onDraw(canvas: Canvas) {
if (navigation == null) return
navigation?.regions?.forEach { region ->
val rect = region.rectF
// Scale rect from 1f,1f to screen width and height
canvas.withScale(width.toFloat(), height.toFloat()) {
regionPaint.color = context.getColor(region.type.colorRes)
drawRect(rect, regionPaint)
}
// Don't want scale anymore because it messes with drawText
// Translate origin to rect start (left, top)
canvas.withTranslation(x = (width * rect.left), y = (height * rect.top)) {
// Calculate center of rect width on screen
val x = width * (abs(rect.left - rect.right) / 2)
// Calculate center of rect height on screen
val y = height * (abs(rect.top - rect.bottom) / 2)
drawText(context.getString(region.type.nameRes), x, y, textBorderPaint)
drawText(context.getString(region.type.nameRes), x, y, textPaint)
}
}
}
override fun performClick(): Boolean {
super.performClick()
if (viewPropertyAnimator == null && isVisible) {
viewPropertyAnimator = animate()
.alpha(0f)
.setDuration(FADE_DURATION)
.withEndAction {
isVisible = false
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
return true
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
// Hide overlay if user start tapping or swiping
performClick()
return super.onTouchEvent(event)
}
}
private const val FADE_DURATION = 1000L
@@ -21,16 +21,12 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.findActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import moe.tarsin.kt.andThen
@Composable
@@ -64,17 +60,3 @@ fun ReaderPageSheetMeta(
Item(icon = Icons.Default.Save, text = R.string.action_save_to, onClick = saveTo)
}
}
@Composable
fun ReaderPageSheet(page: ReaderPage, dismiss: () -> Unit) {
val activity = LocalContext.current.run { remember { findActivity<ReaderActivity>() } }
ReaderPageSheetMeta(
retry = { activity.retryPage(page.index) },
retryOrigin = { activity.retryPage(page.index, true) },
share = { activity.shareImage(page.index) },
copy = { activity.copyImage(page.index) },
save = { activity.saveImage(page.index) },
saveTo = { activity.saveImageTo(page.index) },
dismiss = dismiss,
)
}
@@ -2,17 +2,18 @@ package eu.kanade.tachiyomi.ui.reader.loader
import androidx.annotation.CallSuper
import androidx.collection.lruCache
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.OSUtils
import com.hippo.ehviewer.util.isAtLeastO
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.withIOContext
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
abstract class PageLoader {
private val cache by lazy {
lruCache<Int, Image>(
maxSize = if (isAtLeastO) {
@@ -22,25 +23,33 @@ abstract class PageLoader {
},
sizeOf = { _, v -> v.size.toInt() },
onEntryRemoved = { _, k, o, n ->
n ?: notifyPageWait(k)
o.recycle()
if (o.isRecyclable) {
n ?: notifyPageWait(k)
o.recycle()
} else {
o.isRecyclable = true
}
},
)
}
val pages by lazy {
check(size > 0)
(0 until size).map { ReaderPage(it) }
}
private val preloads = com.hippo.ehviewer.Settings.preloadImage.coerceIn(0, 100)
private val prefetchPageCount = Settings.preloadImage
@CallSuper
open suspend fun awaitReady(): Boolean {
withIOContext { cache }
return true
}
abstract suspend fun awaitReady(): Boolean
abstract val isReady: Boolean
@CallSuper
open fun start() {
cache
}
abstract fun start()
@CallSuper
open fun stop() {
@@ -53,20 +62,35 @@ abstract class PageLoader {
abstract val size: Int
private var lastRequestIndex = -1
fun request(index: Int) {
val image = cache[index]
if (image != null) {
notifyPageSucceed(index, image)
notifyPageSucceed(index, image, false)
} else {
notifyPageWait(index)
onRequest(index)
}
val pagesAbsent = ((index - 5).coerceAtLeast(0) until (preloads + index).coerceAtMost(size))
.mapNotNullTo(mutableListOf()) { it.takeIf { it != index && cache[it] == null } }
// Load forward first, then load backward from the nearest index
pagesAbsent.sortBy { (index - it).coerceAtLeast(0) }
preloadPages(pagesAbsent, (index - 10).coerceAtLeast(0) to (preloads + index + 10).coerceAtMost(size))
// Prefetch to disk
val prefetchRange = if (index >= lastRequestIndex) {
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
} else {
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
}
val pagesAbsent = prefetchRange.filter { pages[it].status.value == Page.State.QUEUE }
val start = if (prefetchRange.step > 0) prefetchRange.first else prefetchRange.last
val end = if (prefetchRange.step > 0) prefetchRange.last else prefetchRange.first
prefetchPages(pagesAbsent, start - 5 to end + 5)
// Prefetch to memory
val range = index - 3..index + 3
pagesAbsent.forEach {
if (it in range) onRequest(it)
}
lastRequestIndex = index
}
fun retryPage(index: Int, orgImg: Boolean = false) {
@@ -74,7 +98,7 @@ abstract class PageLoader {
onForceRequest(index, orgImg)
}
protected abstract fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>)
protected abstract fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>)
protected abstract fun onRequest(index: Int)
@@ -87,16 +111,17 @@ abstract class PageLoader {
protected abstract fun onCancelRequest(index: Int)
fun notifyPageWait(index: Int) {
pages[index].progress = 0
pages[index].status.value = Page.State.QUEUE
}
fun notifyPagePercent(index: Int, percent: Float) {
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
pages[index].progress = (percent * 100).toInt()
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
}
fun notifyPageSucceed(index: Int, image: Image) {
if (cache[index] != image) {
fun notifyPageSucceed(index: Int, image: Image, replaceCache: Boolean = true) {
if (replaceCache) {
cache.put(index, image)
}
pages[index].image = image
@@ -1,116 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.setting
import eu.kanade.tachiyomi.core.preference.AndroidPreferenceStore
import eu.kanade.tachiyomi.core.preference.getEnum
import eu.kanade.tachiyomi.data.preference.PreferenceValues
object ReaderPreferences {
private val preferenceStore = AndroidPreferenceStore
// region General
fun pageTransitions() = preferenceStore.getBoolean("pref_enable_transitions_key", true)
fun doubleTapAnimSpeed() = preferenceStore.getInt("pref_double_tap_anim_speed", 500)
fun showPageNumber() = preferenceStore.getBoolean("pref_show_page_number_key", true)
fun showReaderSeekbar() = preferenceStore.getBoolean("pref_show_reader_seekbar", true)
fun doubleTapToZoom() = preferenceStore.getBoolean("pref_double_tap_to_zoom", true)
fun trueColor() = preferenceStore.getBoolean("pref_true_color_key", false)
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)
fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true)
fun keepScreenOn() = preferenceStore.getBoolean("pref_keep_screen_on_key", true)
fun defaultReadingMode() = preferenceStore.getInt("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
fun defaultOrientationType() = preferenceStore.getInt("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
fun imageScaleType() = preferenceStore.getInt("pref_image_scale_type_key", 1)
fun zoomStart() = preferenceStore.getInt("pref_zoom_start_key", 1)
fun readerTheme() = preferenceStore.getInt("pref_reader_theme_key", 1)
fun alwaysShowChapterTransition() = preferenceStore.getBoolean("always_show_chapter_transition", true)
fun cropBorders() = preferenceStore.getBoolean("crop_borders", false)
fun navigateToPan() = preferenceStore.getBoolean("navigate_pan", true)
fun landscapeZoom() = preferenceStore.getBoolean("landscape_zoom", false)
fun cropBordersWebtoon() = preferenceStore.getBoolean("crop_borders_webtoon", false)
fun webtoonSidePadding() = preferenceStore.getInt("webtoon_side_padding", 0)
fun readerHideThreshold() = preferenceStore.getEnum("reader_hide_threshold", PreferenceValues.ReaderHideThreshold.LOW)
fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false)
fun skipRead() = preferenceStore.getBoolean("skip_read", false)
fun skipFiltered() = preferenceStore.getBoolean("skip_filtered", true)
// endregion
// region Split two page spread
fun dualPageSplitPaged() = preferenceStore.getBoolean("pref_dual_page_split", false)
fun dualPageInvertPaged() = preferenceStore.getBoolean("pref_dual_page_invert", false)
fun dualPageSplitWebtoon() = preferenceStore.getBoolean("pref_dual_page_split_webtoon", false)
fun dualPageInvertWebtoon() = preferenceStore.getBoolean("pref_dual_page_invert_webtoon", false)
// endregion
// region Color filter
fun customBrightness() = preferenceStore.getBoolean("pref_custom_brightness_key", false)
fun customBrightnessValue() = preferenceStore.getInt("custom_brightness_value", 0)
fun colorFilter() = preferenceStore.getBoolean("pref_color_filter_key", false)
fun colorFilterValue() = preferenceStore.getInt("color_filter_value", 0)
fun colorFilterMode() = preferenceStore.getInt("color_filter_mode", 0)
fun grayscale() = preferenceStore.getBoolean("pref_grayscale", false)
fun invertedColors() = preferenceStore.getBoolean("pref_inverted_colors", false)
// endregion
// region Controls
fun readWithLongTap() = preferenceStore.getBoolean("reader_long_tap", true)
fun readWithVolumeKeys() = preferenceStore.getBoolean("reader_volume_keys", false)
fun readWithVolumeKeysInterval() = preferenceStore.getInt("reader_volume_keys_interval", 0)
fun readWithVolumeKeysInverted() = preferenceStore.getBoolean("reader_volume_keys_inverted", false)
fun navigationModePager() = preferenceStore.getInt("reader_navigation_mode_pager", 0)
fun navigationModeWebtoon() = preferenceStore.getInt("reader_navigation_mode_webtoon", 0)
fun pagerNavInverted() = preferenceStore.getInt("reader_tapping_inverted_2", 0)
fun webtoonNavInverted() = preferenceStore.getInt("reader_tapping_inverted_webtoon_2", 0)
fun showNavigationOverlayNewUser() = preferenceStore.getBoolean("reader_navigation_overlay_new_user", true)
fun showNavigationOverlayOnStart() = preferenceStore.getBoolean("reader_navigation_overlay_on_start", false)
fun wideColorGamut() = preferenceStore.getBoolean("wide_color_gamut", false)
// endregion
}
@@ -10,10 +10,6 @@ import com.hippo.ehviewer.icons.reader.LeftToRight
import com.hippo.ehviewer.icons.reader.RightToLeft
import com.hippo.ehviewer.icons.reader.Vertical
import com.hippo.ehviewer.icons.reader.Webtoon
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
@Stable
enum class ReadingModeType(
@@ -37,6 +33,11 @@ enum class ReadingModeType(
WEBTOON, CONTINUOUS_VERTICAL -> true
}
fun isVertical(type: ReadingModeType) = when (type) {
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> false
VERTICAL, WEBTOON, CONTINUOUS_VERTICAL -> true
}
const val MASK = 0x00000007
fun fromPreference(preference: Int?): ReadingModeType = entries.find { it.flagValue == preference } ?: DEFAULT
@@ -47,14 +48,5 @@ enum class ReadingModeType(
}
fun fromSpinner(position: Int?) = entries.find { value -> value.prefValue == position } ?: DEFAULT
fun toViewer(preference: Int?, activity: ReaderActivity): BaseViewer = when (fromPreference(preference)) {
LEFT_TO_RIGHT -> PagerViewer(activity)
RIGHT_TO_LEFT -> PagerViewer(activity, true)
VERTICAL -> PagerViewer(activity, isVertical = true)
WEBTOON -> WebtoonViewer(activity)
CONTINUOUS_VERTICAL -> WebtoonViewer(activity, isContinuous = false)
DEFAULT -> PagerViewer(activity)
}
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.setting
enum class TappingInvertMode(
val shouldInvertHorizontal: Boolean = false,
val shouldInvertVertical: Boolean = false,
) {
NONE,
HORIZONTAL(shouldInvertHorizontal = true),
VERTICAL(shouldInvertVertical = true),
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true),
}
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
/**
* Interface for implementing a viewer.
*/
interface BaseViewer {
val activity: ReaderActivity
val readerConfig: ReaderPageImageView.Config
val isRtl: Boolean
fun getViewForInsets() = getView()
/**
* Returns the view this viewer uses.
*/
fun getView(): View
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
fun destroy()
/**
* Tells this viewer to set the given [provider] as active.
*/
fun setGalleryProvider(provider: PageLoader)
/**
* Tells this viewer to move to the given [page].
*/
fun moveToPage(page: ReaderPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
fun refreshAdapter()
}
@@ -1,75 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
/**
* A custom gesture detector that also implements an on long tap confirmed, because the built-in
* one conflicts with the quick scale feature.
*/
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener,
) : GestureDetector(context, listener) {
private val handler = Handler(Looper.getMainLooper())
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
/**
* Runnable to execute when a long tap is confirmed.
*/
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
// This is the key difference with the built-in detector. We have to ignore the
// event if the last up and current down are too close in time (double tap).
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
/**
* Custom listener to also include a long tap confirmed
*/
open class Listener : SimpleOnGestureListener() {
/**
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
*/
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}
@@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
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.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.theme.EhTheme
class ReaderErrorLayout @JvmOverloads constructor(
context: Context,
private val errorMsg: String? = null,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
private val onRetry: () -> Unit = {},
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
}
@Composable
override fun Content() = EhTheme {
val defaultError = stringResource(id = R.string.decode_image_error)
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = errorMsg ?: defaultError,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = onRetry, modifier = Modifier.padding(8.dp)) {
Text(text = stringResource(id = R.string.action_retry))
}
}
}
}
}
private const val DEFAULT_ASPECT = 1 / 1.4125f
@@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
/**
* RecyclerView Adapter used by this [viewer] to where [PageLoader] updates are posted.
*/
class ReaderPageAdapter(
val viewer: BaseViewer,
private val isWebtoon: Boolean = false,
) : RecyclerView.Adapter<ReaderPageHolder>() {
/**
* List of currently set items.
*/
var items: List<ReaderPage> = emptyList()
private set
private var currentChapter: PageLoader? = null
/**
* Context that has been wrapped to use the correct theme values based on the
* current app theme and reader background color
*/
private var readerThemedContext = viewer.activity.createReaderThemeContext()
/**
* Updates this adapter with the given [chapter]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapter: PageLoader) {
currentChapter = chapter
items = chapter.pages
}
fun refresh() {
readerThemedContext = viewer.activity.createReaderThemeContext()
}
/**
* Returns the amount of items of the adapter.
*/
override fun getItemCount(): Int = items.size
/**
* Creates a new view holder for an item with the given [viewType].
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReaderPageHolder {
val view = ReaderPageImageView(readerThemedContext, isWebtoon = isWebtoon)
return ReaderPageHolder(view, viewer)
}
/**
* Binds an existing view [holder] with the item at the given [position].
*/
override fun onBindViewHolder(holder: ReaderPageHolder, position: Int) {
val item = items[position]
currentChapter!!.request(position)
holder.bind(item)
}
/**
* Recycles an existing view [holder] before adding it to the view pool.
*/
override fun onViewRecycled(holder: ReaderPageHolder) {
currentChapter?.cancelRequest(holder.bindingAdapterPosition)
holder.recycle()
}
}
@@ -1,208 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.content.res.Resources
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Holder of the reader for a single page of a chapter.
*
* @param frame the root view for this holder.
* @param viewer the page viewer.
* @constructor creates a new page holder.
*/
class ReaderPageHolder(
private val frame: ReaderPageImageView,
private val viewer: BaseViewer,
) : RecyclerView.ViewHolder(frame) {
/**
* Context getter because it's used often.
*/
val context: Context get() = frame.context
/**
* Loading progress bar to indicate the current progress.
* Needed to keep a minimum height size of the holder, otherwise the adapter would create more
* views to fill the screen, which is not wanted.
*/
private val progressIndicator = ReaderProgressIndicator(context)
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderErrorLayout? = null
/**
* Page of a chapter.
*/
private lateinit var page: ReaderPage
private val scope = CoroutineScope(Dispatchers.IO)
/**
* Subscription for status changes of the page.
*/
private var statusJob: Job? = null
init {
if (viewer is WebtoonViewer) {
val defaultHeight = (viewer.recycler.width * 1.4125).toInt()
frame.addView(progressIndicator, MATCH_PARENT, defaultHeight)
refreshLayoutParams()
} else {
frame.addView(progressIndicator)
frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
frame.onImageLoaded = ::onImageLoaded
frame.onScaleChanged = { viewer.activity.hideMenu() }
}
/**
* Binds the given [page] with this view holder, subscribing to its state.
*/
fun bind(page: ReaderPage) {
this.page = page
statusJob?.cancel()
statusJob = scope.launch(Dispatchers.Main) {
page.status.collectLatest {
processStatus(it)
}
}
refreshLayoutParams()
}
private fun refreshLayoutParams() {
if (viewer is WebtoonViewer) {
frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
if (!viewer.isContinuous) {
bottomMargin = 15.dpToPx
}
val margin = Resources.getSystem().displayMetrics.widthPixels * (viewer.config.sidePadding / 100f)
marginEnd = margin.toInt()
marginStart = margin.toInt()
}
}
}
fun onPageSelected(forward: Boolean) = frame.onPageSelected(forward)
fun panEnd(reverse: Boolean) = if (reverse) frame.panLeft() else frame.panRight()
/**
* Called when the view is recycled and added to the view pool.
*/
fun recycle() {
statusJob?.cancel()
statusJob = null
removeErrorLayout()
frame.recycle()
progressIndicator.reset()
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private suspend fun processStatus(status: Page.State) {
when (status) {
Page.State.QUEUE -> setQueued()
Page.State.LOAD_PAGE -> setLoading()
Page.State.DOWNLOAD_IMAGE -> {
setDownloading()
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
}
Page.State.READY -> page.image?.let { setImage(it) } ?: setError()
Page.State.ERROR -> setError()
}
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progressIndicator.setProgress(0)
progressIndicator.show()
removeErrorLayout()
frame.recycle()
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progressIndicator.show()
removeErrorLayout()
}
/**
* Called when the page is downloading
*/
private fun setDownloading() {
progressIndicator.show()
removeErrorLayout()
}
/**
* Called when the page is ready.
*/
private fun setImage(image: Image) {
progressIndicator.setProgress(0)
frame.setImage(image, viewer.readerConfig)
}
/**
* Called when the page has an error.
*/
private fun setError() {
progressIndicator.hide()
initErrorLayout()
}
/**
* Called when the image is displayed.
*/
private fun onImageLoaded() {
progressIndicator.hide()
removeErrorLayout()
}
/**
* Initializes a button to retry pages.
*/
private fun initErrorLayout() {
if (errorLayout == null) {
errorLayout = ReaderErrorLayout(context, page.errorMsg) {
viewer.activity.retryPage(page.index)
}.also {
frame.addView(it)
}
}
}
/**
* Removes the decode error layout from the holder, if found.
*/
private fun removeErrorLayout() {
errorLayout?.let {
frame.removeView(it)
errorLayout = null
}
}
}
@@ -1,304 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.ui.graphics.toAndroidRect
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.DrawableImage
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.SCALE_TYPE_CENTER_INSIDE
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import io.getstream.photoview.PhotoView
/**
* A wrapper view for showing page image.
*
* Animated image will be drawn by [PhotoView] while [SubsamplingScaleImageView] will take non-animated image.
*
* @param isWebtoon if true, [WebtoonSubsamplingImageView] will be used instead of [SubsamplingScaleImageView]
* and [AppCompatImageView] will be used instead of [PhotoView]
*/
class ReaderPageImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttrs: Int = 0,
@StyleRes defStyleRes: Int = 0,
private val isWebtoon: Boolean = false,
) : FrameLayout(context, attrs, defStyleAttrs, defStyleRes) {
private var pageView: View? = null
private var config: Config? = null
var onImageLoaded: (() -> Unit)? = null
var onScaleChanged: ((newScale: Float) -> Unit)? = null
/**
* For automatic background. Will be set as background color when [onImageLoaded] is called.
*/
var pageBackground: Drawable? = null
private fun onImageLoaded() {
onImageLoaded?.invoke()
background = pageBackground
}
fun onScaleChanged(newScale: Float) {
onScaleChanged?.invoke(newScale)
}
fun onPageSelected(forward: Boolean) {
with(pageView as? SubsamplingScaleImageView) {
if (this == null) return
if (isReady) {
landscapeZoom(forward)
} else {
setOnImageEventListener {
setupZoom(config)
landscapeZoom(forward)
this@ReaderPageImageView.onImageLoaded()
}
}
}
}
private fun SubsamplingScaleImageView.landscapeZoom(forward: Boolean) {
if (config != null && config!!.landscapeZoom && config!!.minimumScaleType == SCALE_TYPE_CENTER_INSIDE && sWidth > sHeight && scale == getMinScale()) {
handler?.postDelayed(500) {
val point = when (config!!.zoomStartPosition) {
ZoomStartPosition.LEFT -> if (forward) PointF(0F, 0F) else PointF(sWidth.toFloat(), 0F)
ZoomStartPosition.RIGHT -> if (forward) PointF(sWidth.toFloat(), 0F) else PointF(0F, 0F)
ZoomStartPosition.CENTER -> center.also { it?.y = 0F }
}
val targetScale = height.toFloat() / sHeight.toFloat()
animateScaleAndCenter(targetScale, point)!!
.withDuration(500)
.withEasing(SubsamplingScaleImageView.EASE_IN_OUT_QUAD)
.withInterruptible(true)
.start()
}
}
}
fun setImage(drawable: Image, config: Config) {
this.config = config
val image = drawable.innerImage ?: return
if (image is DrawableImage) {
prepareAnimatedImageView()
setAnimatedImage(image.drawable, config)
}
if (image is BitmapImage) {
prepareNonAnimatedImageView()
setNonAnimatedImage(image.bitmap, drawable.rect.toAndroidRect(), config)
}
}
fun recycle() = pageView?.let {
when (it) {
is SubsamplingScaleImageView -> it.recycle()
}
it.isVisible = false
}
/**
* Check whether the image can be panned.
* @param fn a function that returns the direction to check for
*/
private fun canPan(fn: (RectF) -> Float): Boolean {
(pageView as? SubsamplingScaleImageView)?.let { view ->
RectF().let {
view.getPanRemaining(it)
return fn(it) > 1
}
}
return false
}
/**
* Pans the image to the left by a screen's width worth.
*/
fun panLeft(): Boolean {
val canPan = canPan { it.left }
if (canPan) {
pan { center, view -> center.also { it.x -= view.width / view.scale } }
}
return canPan
}
/**
* Pans the image to the right by a screen's width worth.
*/
fun panRight(): Boolean {
val canPan = canPan { it.right }
if (canPan) {
pan { center, view -> center.also { it.x += view.width / view.scale } }
}
return canPan
}
/**
* Pans the image.
* @param fn a function that computes the new center of the image
*/
private fun pan(fn: (PointF, SubsamplingScaleImageView) -> PointF) {
(pageView as? SubsamplingScaleImageView)?.let { view ->
val target = fn(view.center ?: return, view)
view.animateCenter(target)!!
.withEasing(EASE_OUT_QUAD)
.withDuration(250)
.withInterruptible(true)
.start()
}
}
private fun prepareNonAnimatedImageView() {
if (pageView is SubsamplingScaleImageView) return
removeView(pageView)
pageView = if (isWebtoon) {
WebtoonSubsamplingImageView(context)
} else {
SubsamplingScaleImageView(context)
}.apply {
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setOnStateChangedListener(
object : SubsamplingScaleImageView.OnStateChangedListener {
override fun onScaleChanged(newScale: Float, origin: Int) {
this@ReaderPageImageView.onScaleChanged(newScale)
}
override fun onCenterChanged(newCenter: PointF?, origin: Int) {
// Not used
}
},
)
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
}
private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
// 5x zoom
maxScale = scale * MAX_ZOOM_SCALE
setDoubleTapZoomScale(scale * 2)
when (config?.zoomStartPosition) {
ZoomStartPosition.LEFT -> setScaleAndCenter(scale, PointF(0F, 0F))
ZoomStartPosition.RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0F))
ZoomStartPosition.CENTER -> setScaleAndCenter(scale, center.also { it?.y = 0F })
null -> {}
}
}
private fun setNonAnimatedImage(
image: Bitmap,
bounds: Rect,
config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
setMinimumScaleType(config.minimumScaleType)
setMinimumDpi(1) // Just so that very small image will be fit for initial load
setOnImageEventListener {
setupZoom(config)
if (isVisibleOnScreen()) landscapeZoom(true)
this@ReaderPageImageView.onImageLoaded()
}
setImage(image, bounds)
isVisible = true
}
private fun prepareAnimatedImageView() {
if (pageView is AppCompatImageView) return
removeView(pageView)
pageView = if (isWebtoon) {
AppCompatImageView(context)
} else {
PhotoView(context)
}.apply {
adjustViewBounds = true
if (this is PhotoView) {
setScaleLevels(1F, 2F, MAX_ZOOM_SCALE)
// Force 2 scale levels on double tap
setOnDoubleTapListener(
object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (ReaderPreferences.doubleTapToZoom().get()) {
if (scale > 1F) {
setScale(1F, e.x, e.y, true)
} else {
setScale(2F, e.x, e.y, true)
}
}
return true
}
},
)
setOnScaleChangeListener { _, _, _ ->
this@ReaderPageImageView.onScaleChanged(scale)
}
}
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
}
private fun setAnimatedImage(
image: Drawable,
config: Config,
) = (pageView as? AppCompatImageView)?.apply {
if (this is PhotoView) {
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration())
}
setImageDrawable(image)
(image as? Animatable)?.start()
isVisible = true
this@ReaderPageImageView.onImageLoaded()
}
private fun Int.getSystemScaledDuration(): Int = (this * context.animatorDurationScale).toInt().coerceAtLeast(1)
/**
* All of the config except [zoomDuration] will only be used for non-animated image.
*/
data class Config(
val zoomDuration: Int,
val minimumScaleType: Int = SCALE_TYPE_CENTER_INSIDE,
val cropBorders: Boolean = false,
val zoomStartPosition: ZoomStartPosition = ZoomStartPosition.CENTER,
val landscapeZoom: Boolean = false,
)
enum class ZoomStartPosition {
LEFT,
CENTER,
RIGHT,
}
}
private const val MAX_ZOOM_SCALE = 5F
@@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.IntRange
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.isVisible
import com.hippo.ehviewer.ui.theme.EhTheme
class ReaderProgressIndicator @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
}
private var progress by mutableFloatStateOf(0f)
private var visible by mutableStateOf(false)
@Composable
override fun Content() {
EhTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (visible) CombinedCircularProgressIndicator(progress = progress)
}
}
}
fun show() {
visible = true
isVisible = true
}
fun hide() {
visible = false
isVisible = false
}
fun reset() {
progress = 0f
visible = false
isVisible = true
}
fun setProgress(@IntRange(from = 0, to = 100) progress: Int) {
this.progress = progress / 100f
}
}
@@ -1,102 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Common configuration for all viewers.
*/
abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val scope: CoroutineScope) {
var imagePropertyChangedListener: (() -> Unit)? = null
var navigationModeChangedListener: (() -> Unit)? = null
var tappingInverted = TappingInvertMode.NONE
var longTapEnabled = true
var usePageTransitions = false
var doubleTapAnimDuration = 500
var volumeKeysEnabled = false
var volumeKeysInterval = 0
var volumeKeysInverted = false
var trueColor = false
var alwaysShowChapterTransition = true
var navigationMode = 0
protected set
var forceNavigationOverlay = false
var navigationOverlayOnStart = false
var dualPageSplit = false
protected set
var dualPageInvert = false
protected set
abstract var navigator: ViewerNavigation
protected set
init {
readerPreferences.readWithLongTap()
.register({ longTapEnabled = it })
readerPreferences.pageTransitions()
.register({ usePageTransitions = it })
readerPreferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
readerPreferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
readerPreferences.readWithVolumeKeysInterval()
.register({ volumeKeysInterval = it })
readerPreferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
readerPreferences.trueColor()
.register({ trueColor = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.alwaysShowChapterTransition()
.register({ alwaysShowChapterTransition = it })
forceNavigationOverlay = readerPreferences.showNavigationOverlayNewUser().get()
if (forceNavigationOverlay) {
readerPreferences.showNavigationOverlayNewUser().set(false)
}
readerPreferences.showNavigationOverlayOnStart()
.register({ navigationOverlayOnStart = it })
}
protected abstract fun defaultNavigation(): ViewerNavigation
abstract fun updateNavigation(navigationMode: Int)
fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {},
) {
changes()
.onEach { valueAssignment(it) }
.distinctUntilChanged()
.onEach { onChanged(it) }
.launchIn(scope)
}
fun <T> Flow<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {},
) = onEach { valueAssignment(it) }
.distinctUntilChanged()
.onEach { onChanged(it) }
.launchIn(scope)
}
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more