Compare commits

..
231 Commits
Author SHA1 Message Date
FooIbar dd2949638c Prepare for release 1.12.1
Releases / build (push) Has been cancelled
2024-09-28 22:57:17 +08:00
FooIbar f71aa4902b Prepare changelog for 1.12.1 (#1691)
[no ci]
2024-09-28 22:56:20 +08:00
FooIbar af7e411932 Refine GalleryMultiPageViewerPTokenParser 2024-09-28 00:01:21 +08:00
FooIbar c54ac1a4c0 Fix crash when download location is invalid 2024-09-27 19:57:47 +08:00
renovate[bot] 00d6a2e403 Update AGP to v8.8.0-alpha04 2024-09-27 02:08:47 +00:00
FooIbar b67d0938b0 Reapply "Add scroll bar to quick search and download label"
This reverts commit a52ab28534.
2024-09-26 19:12:46 +08:00
FooIbar 8447760cb5 Fix image download queue not respecting FIFO order 2024-09-25 22:58:36 +08:00
FooIbar 24d2072586 Use Downloads as start screen if device is offline (#1675) 2024-09-25 03:54:34 +00:00
renovate[bot] a52ab28534 Revert "Add scroll bar to quick search and download label"
This reverts commit 601713d8d7.
2024-09-25 08:25:04 +08:00
FooIbar 601713d8d7 Add scroll bar to quick search and download label 2024-09-25 07:38:20 +08:00
FooIbar 7bfa5be53c Fix concurrent creation of the same directory 2024-09-24 21:36:42 +08:00
renovate[bot] ae3a66903a Update dependency gradle to v8.10.2 2024-09-24 02:16:59 +00:00
renovate[bot] 5438fd5d9b Update AGP to v8.8.0-alpha03 2024-09-23 21:59:33 +00:00
FooIbar a74ffca03d Fix galleries with both artist and cosplayer tags
Legacy issue of E-Hentai's tag namespacing changes.
2024-09-24 00:38:24 +08:00
renovate[bot] c5a83d567e Update dependency libarchive to v3.7.6 2024-09-23 13:40:44 +00:00
FooIbar 1b1f781dd9 Update renovate config 2024-09-23 21:32:34 +08:00
renovate[bot] 988195e4fc Lock file maintenance 2024-09-23 00:49:24 +00:00
FooIbar 4d4124458d Fix reader prefetch when scroll direction changes 2024-09-22 16:19:24 +08:00
FooIbar 0ae2843676 Actually fix data import on Android V
This reverts commit 26921c8a
2024-09-21 23:10:50 +08:00
FooIbar beaddc9eeb Fix foreign key constraint violation on db import 2024-09-21 23:10:50 +08:00
FooIbar d843b3a14b Try to reduce false positives in ad detection 2024-09-21 20:05:01 +08:00
Dude so hot b9da0eda8b Cleanup 2024-09-20 20:01:49 +08:00
Dude so hot ba75cda036 Move navigation to use compose Rect 2024-09-20 20:01:49 +08:00
Dude so hot 9d59af29c5 Singleton Navigation 2024-09-20 20:01:49 +08:00
FooIbar 660d6e32f1 Cleanup 2024-09-20 19:50:13 +08:00
FooIbar c07c663105 Address deprecation 2024-09-20 19:50:13 +08:00
FooIbar 3673f6f7f9 Rename awaitPermissionOrCancel -> awaitConfirmationOrCancel 2024-09-20 19:50:13 +08:00
renovate[bot]andDude so hot e8120e12d0 ADT Page model (#1655)
* Enable when guards

* ADT Page model

* Refine

* Refine

* Remove type conversion

* Cleanup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-20 16:19:59 +08:00
renovate[bot] f7459886b2 Update kotlinx.serialization to v1.7.3 2024-09-19 20:07:38 +00:00
FooIbar 6fe5319c08 Update Kotlin & KSP to 2.1.0-Beta1 2024-09-19 23:57:48 +08:00
FooIbar 3a42208a36 Merge GalleryPreviewScreen to GalleryDetailScreen (#1649) 2024-09-19 23:39:42 +08:00
FooIbar e4eacce5bf Update Compose BOM 2024-09-19 23:02:04 +08:00
FooIbar d1fdea8d3e Update libarchive to v3.7.5 2024-09-19 22:57:38 +08:00
98e452e39e Fastpath to create regioned hardware bitmap (#1647)
* kotlin side

* Impl

* Refine

* respect ahb align

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Don't copy unnecessary things

* Cleanup

* Recycle

* Revert "Disable `abortOnError` for lint in debug build"

This reverts commit cea06f0c

* Fix marshmallow build

* Fix cmake

* Address cr

* Refine

* Cleanup

* log it

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-09-19 14:25:55 +00:00
FooIbar 02e8fcc534 Fix lint
This reverts commit cea06f0c
2024-09-19 21:54:28 +08:00
renovate[bot]andDude so hot 70852c2725 Crop bitmap before draw (#1646)
* Crop bitmap before draw

* Spilt

* Refine

* Cleanup

* Address code review

* Address code review

* Address code review

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-19 07:56:24 +00:00
renovate[bot] ea67d6e078 Update benchmark to v1.4.0-alpha01 2024-09-18 22:15:18 +00:00
renovate[bot] d2aa3b5347 Update androidx.room to v2.7.0-alpha08 2024-09-18 22:14:40 +00:00
renovate[bot] 937792ba91 Update dependency androidx.work:work-runtime to v2.10.0-alpha04 2024-09-18 18:53:24 +00:00
renovate[bot] fcfdaf59f8 Update dependency androidx.lifecycle:lifecycle-process to v2.9.0-alpha03 2024-09-18 17:22:46 +00:00
renovate[bot] 0d4da998a1 Update dependency androidx.profileinstaller:profileinstaller to v1.4.0 2024-09-18 17:21:32 +00:00
FooIbar 0e7a32d9af [Marshmallow] Add ISRG Root X1 as a trusted root 2024-09-18 17:54:41 +08:00
FooIbar 4bb6b0baf6 Switch to rxing for QR code detection 2024-09-16 17:54:14 +08:00
renovate[bot] 0591b3224f Lock file maintenance 2024-09-16 00:10:00 +00:00
renovate[bot]andDude so hot eaf8dd21ff Crop border in Rust (#1631)
* Fix deref_mut_direct_bytebuffer lifetime

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Framework

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Add Infra

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Implement

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Impl

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Fixes

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Implement

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Update profile code

* Remove corrent but slow code that slow for 1000x

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Revert "Remove corrent but slow code that slow for 1000x"

This reverts commit 644d093ddc20f4a129ee304157767707337d149d.

* Fix speed

Signed-off-by: Dude so hot <djohn@fbi.gov>

* refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* cleanup type conversion

Signed-off-by: Dude so hot <djohn@fbi.gov>

* cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Remove C cropborder impl

* impl nth to accel

Signed-off-by: Dude so hot <djohn@fbi.gov>

* F**K

Signed-off-by: Dude so hot <djohn@fbi.gov>

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-15 22:20:55 +08:00
Dude so hot 9bc6cccf26 buffer share same lifetime with jnienv
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot eb3b53ee5b Format
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 731af586af Add safety hint for parse_marshal_inplace
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 3acb70b8e1 Add safety hint for deref_mut_direct_bytebuffer
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 836b1841e3 Add safety hint for with_bitmap_content
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot d37f4580a9 Rename to avoid clash
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 70fc964b4d Fold get_bitmap_handle
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot c3c5fbfc79 Extract with_bitmap_content
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 04103f9280 Extract detect_image_ad
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 69370efcf5 Modularize QrCode
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
renovate[bot]andDude so hot f0d3b0c8ae Misc updates (#1629)
* Respect Arch's ndk location

* Don't spam in debug build

* Cleanup

* Revert "Cleanup"

This reverts commit 45b9d76dd363aa1fd3d58b5102b68649028e313a.

* Revert "Respect Arch's ndk location"

This reverts commit 3fd773422d3ae440a6466e2fa161a45018fd8ed7.

* Respect Arch ndk location

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-14 10:45:36 +00:00
FooIbar 370c485048 Update NDK to r27b and address deprecation 2024-09-14 17:07:04 +08:00
FooIbar 7e4c910cd7 Fix blocked image placeholder content scale 2024-09-14 16:06:09 +08:00
renovate[bot] e8c1c80ce5 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.9.0 2024-09-13 16:20:18 +00:00
0d55f319c9 Fuckoff External Ads (#1617)
* Add Rust implementation to detect QRCode from bitmap

* Add QRCodeInterceptor Framework

* Let image take hasQRCode info

* Keep ReaderPage index internal

* Move imageCache to use ReaderPage as key

* Make PageLoader size & pages internal

* Refine

* Kill entry if QRCode

* Rewrite request to respect index mapping

* Debug: enable by default

* Fix crash

* Fixup

* Fix identity

* Revert to lruCache to avoid crash

* Fixup

* Clippy check for android only

* Fixup

* Resolve clippy

* Switch

* Apply switch

* Restart pageloader restore pages

* Cleanup

* Cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* let it inference

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Don't detect border for ads

* Make coil interceptor runs in `Dispatchers.Default`

* Revert "Restart pageloader restore pages"

This reverts commit 8fd02fb7d9c677c119df72a767a6079f1899dc31.

* Revert drop page changes

* Use backGround color painter for ads

* Only check ads for tags contains extraneous ads

* Only check ads for head 5 images || tail 10 images

* Move strip ADs switch to AdvancedScreen

* Pick placeholder

* Show Placeholder

* Don't try to extract & decode

Signed-off-by: Dude so hot <djohn@fbi.gov>

* cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Naming

* Naming

* Naming

* Naming

* For ArchivePageLoader as well

* Hint

* Fix flicker

* Refine

* Naming

* QR -> Qr

* Naming

* QR -> Qr

* Naming

* Refine

* Revert

* Remove header check

* Allow show ads

* Adjust

* Refine

* Revert "Don't try to extract & decode"

This reverts commit f35ef46f6ffc81485cf0f4c646ed79e524c29d32.

Signed-off-by: Dude so hot <djohn@fbi.gov>

* be strict

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Just Extract, don't decode

* Refine

* Refine placeholder

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-09-13 16:10:05 +00:00
FooIbar 42ed51094e Temporarily disable shared element transitions (#1621) 2024-09-13 12:10:26 +08:00
renovate[bot] 84c3fee5da Update dependency sh.calvin.reorderable:reorderable to v2.4.0-beta01 2024-09-13 00:33:59 +00:00
renovate[bot] b3af2c9481 Update AGP to v8.8.0-alpha02 2024-09-12 22:42:21 +00:00
renovate[bot] 33cab186fd Update dependency com.squareup.okio:okio-jvm to v3.9.1 2024-09-12 22:40:33 +00:00
FooIbar e3f52412ee Replace custom user agent with desktop site option 2024-09-12 20:22:54 +08:00
080810c707 Update dependency io.ktor:ktor-client-core to v3.0.0-rc-1 (#1424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-09-10 01:22:52 +00:00
renovate[bot] 964e827cbe Update AGP to v8.8.0-alpha01 2024-09-10 01:10:26 +00:00
FooIbar cae02acfc4 Fix cosplayer tag translation in download label 2024-09-10 06:11:25 +08:00
FooIbar 31ce4eac92 Update description for IP-based image limit 2024-09-09 22:13:49 +08:00
renovate[bot] bd5bd1e571 Implement Shared Element Transition Connector Framework (#1614)
undefined
2024-09-09 21:46:07 +08:00
renovate[bot] 2b4a6dc57a Update dependency gradle to v8.10.1 2024-09-09 10:25:31 +00:00
FooIbar 68932731b9 Fix gallery detail losing scroll position 2024-09-09 18:14:48 +08:00
renovate[bot] aab7ba0d53 Lock file maintenance 2024-09-09 01:22:24 +00:00
renovate[bot] 59bf4f2702 Update compose.destinations to v2.1.0-beta12 2024-09-08 22:47:54 +00:00
FooIbar e0e4b739f9 Add key and contentType for gallery detail 2024-09-08 22:04:12 +08:00
FooIbar 21d6b3bbb8 [no ci] Fix renovate config 2024-09-07 18:13:20 +08:00
FooIbar dae5d67515 [no ci] Fix renovate config 2024-09-07 17:59:56 +08:00
FooIbar a6a814bbbb [no ci] Update renovate config 2024-09-07 16:27:23 +08:00
FooIbar 0b0555af9d Use singleton empty WindowInsets 2024-09-07 16:13:54 +08:00
FooIbar 6e0d77a8ab Fix unfinished files being considered downloaded 2024-09-07 16:13:40 +08:00
FooIbar 5c06a69c8a Revert "Update agp to v8.7.0-beta01"
This reverts commit 63ec1aa217.
2024-09-06 14:25:38 +08:00
FooIbar 188a236a90 Fix reader padding and progress indicator theme 2024-09-06 10:00:21 +08:00
renovate[bot] 63ec1aa217 Update agp to v8.7.0-beta01 2024-09-05 21:38:21 +00:00
renovate[bot] 829316d8b9 Update dependency com.google.devtools.ksp to v2.0.20-1.0.25 2024-09-05 21:36:55 +00:00
renovate[bot] 337739fdad Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-rc01 2024-09-05 14:44:41 +00:00
FooIbar f144ac701d Refine BitmapPainter 2024-09-05 22:36:19 +08:00
FooIbar 9c9b8b74cd Fix crash on random button click with empty list 2024-09-05 20:53:00 +08:00
renovate[bot] 154c7f3710 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.36.0 2024-09-05 03:38:28 +00:00
renovate[bot] d3308bc987 Update dependency androidx.activity:activity-compose to v1.10.0-alpha02 2024-09-05 01:56:06 +00:00
renovate[bot] 04d9ee57d7 Update dependency androidx.core:core-splashscreen to v1.2.0-alpha02 2024-09-05 01:45:16 +00:00
FooIbar f3eecf18fb Switch to official Compose alpha BOM
This reverts commit c76440f167.
2024-09-05 09:45:04 +08:00
renovate[bot] 6591495d2d Update dependency androidx.work:work-runtime to v2.10.0-alpha03 2024-09-05 01:43:42 +00:00
renovate[bot] 2497835628 Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-beta01 2024-09-04 22:35:38 +00:00
renovate[bot] 8fad9b2abe Update androidx.lifecycle to v2.9.0-alpha02 2024-09-04 22:34:46 +00:00
renovate[bot] fdd0ec9fd2 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.2 2024-09-04 10:52:07 +00:00
FooIbar 1219ff99ff Reset page state on page loader restart 2024-09-03 19:12:01 +08:00
FooIbar c60b14998a Fix crash when drawing > 100 MB bitmaps 2024-09-03 19:03:54 +08:00
renovate[bot] 380554bd19 Lock file maintenance 2024-09-02 01:56:54 +00:00
FooIbar 2b969030da Fix recent download label can't be set to null
This reverts commit 822a461acd.
2024-09-01 20:56:01 +08:00
github-actions[bot] da917de1f4 Update Chrome version for default User-Agent 2024-09-01 13:34:46 +08:00
renovate[bot] 541f9e89f3 Update agp to v8.7.0-alpha09 2024-08-30 15:14:11 +00:00
renovate[bot] f4a987c83f Update agp to v8.7.0-alpha08 2024-08-28 19:45:33 +00:00
renovate[bot] 37474ed6ac Update kotlinx.serialization to v1.7.2 2024-08-28 19:43:12 +00:00
renovate[bot] cb69a168f3 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.1 2024-08-28 13:34:26 +00:00
FooIbar 94366553de Fix torrent count parsing
This partially reverts commit 634269c6a4.
2024-08-27 07:37:30 +08:00
FooIbar 383332bfbb Fix account-based image limit parsing (#1565)
* Fix account-based image limit parsing

* Fix format
2024-08-27 07:37:05 +08:00
renovate[bot] 4bb1a17d39 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.0 2024-08-26 17:43:40 +00:00
renovate[bot] 94d68fe38b Lock file maintenance 2024-08-26 00:56:27 +00:00
renovate[bot] cf6c850d2a Update aboutlibraries to v11.2.3 2024-08-23 11:06:34 +00:00
renovate[bot] c8a3a0fc20 Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha02 2024-08-23 01:22:29 +00:00
renovate[bot] 433896b267 Update dependency com.google.devtools.ksp to v2.0.20-1.0.24 2024-08-22 23:11:02 +00:00
renovate[bot] 7e9c1f00ef Update Kotlin & KSP to v2.0.20 2024-08-22 10:56:10 +00:00
renovate[bot] 2b5b0d695a Update dependency me.saket.telephoto:zoomable to v0.13.0 2024-08-22 03:41:35 +00:00
renovate[bot] 2265b0943e Update dependency androidx.core:core-ktx to v1.15.0-alpha02 2024-08-22 01:09:20 +00:00
renovate[bot] 78f12065ab Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha02 2024-08-21 22:04:09 +00:00
renovate[bot] f28c45a13a Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-beta01 2024-08-21 22:03:53 +00:00
renovate[bot] e030500917 Update benchmark to v1.3.0 2024-08-21 21:01:47 +00:00
renovate[bot] 9c042cd323 Update androidx.room to v2.7.0-alpha07 2024-08-21 21:01:30 +00:00
FooIbar 44eea7a273 Adapt status bar content color for reader theme 2024-08-21 15:20:25 +08:00
FooIbar 834cfc5e8c Support scrolling with mouse wheel in reader 2024-08-21 15:20:16 +08:00
renovate[bot] 876bb68873 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.1 2024-08-20 17:10:58 +00:00
FooIbar 40c1b7ef21 Prepare for next iteration 2024-08-19 21:02:30 +08:00
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
199 changed files with 4441 additions and 88283 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
+4 -19
View File
@@ -35,16 +35,13 @@ jobs:
- name: Clippy Check
working-directory: ./app/src/main/rust
run: cargo clippy --all-targets --all-features -- -D warnings
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Spotless Check
run: ./gradlew spotlessCheck
@@ -63,9 +60,6 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
@@ -77,10 +71,9 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
@@ -90,8 +83,6 @@ jobs:
restore-keys: cmake-default-
- name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleDefaultRelease
- name: Upload ARM64
@@ -136,9 +127,6 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
@@ -150,10 +138,9 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
@@ -163,8 +150,6 @@ jobs:
restore-keys: cmake-marshmallow-
- name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleMarshmallowRelease
- name: Upload ARM64
+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: |
+71 -1
View File
@@ -1,6 +1,76 @@
# 变更日志
## [1.11.6] - 2024-06-24
## [1.12.1] - 2024-09-28
### 新功能
* 屏蔽含有二维码的图片(位于高级设置)
### 改进
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
* 画廊预览与画廊详情页面合并
* 为快速搜索和下载标签列表添加了滚动条
* 无网络条件下启动时自动转到下载页面
* 杂项优化
### Bug 修复
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
* 加载大于 100 MB 的位图时崩溃
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
* 选择无效的下载位置后闪退
* 杂项修复
## [1.12.0] - 2024-08-19
### 重大变更
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
### 改进
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
### Bug 修复
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
* 关闭打开失败的压缩包时崩溃
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
* 图片未加载完成时无法通过点击翻页
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
* 缩略图分辨率设置不起作用
## [1.11.7] - 2024-07-10
### Bug 修复
* 无法下载标题包含 # 或 % 的画廊
* 下载页数较多的画廊时应用无响应
* 退出搜索栏时文本闪烁
## [1.11.6] - 2024-07-09 [YANKED]
### 改进
-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
+28 -39
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,10 +21,27 @@ plugins {
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = if (isRelease) 35 else 34
compileSdk = 35
buildToolsVersion = "35.0.0"
ndkVersion = "27.0.11902837-rc1"
androidResources.generateLocaleConfig = true
ndkVersion = "27.1.12297006"
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
splits {
abi {
@@ -64,38 +81,18 @@ android {
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.')
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
?: System.getenv("GITHUB_TOKEN").orEmpty()
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 35
versionCode = 180057
versionName = "1.11.6"
versionCode = 180060
versionName = "1.12.1"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
),
)
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
@@ -153,9 +150,6 @@ android {
}
debug {
applicationIdSuffix = ".debug"
lint {
abortOnError = false
}
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
@@ -169,15 +163,13 @@ android {
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
}
namespace = "com.hippo.ehviewer"
}
composeCompiler {
enableNonSkippingGroupOptimization = true
enableStrongSkippingMode = true
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
androidComponents {
@@ -223,15 +215,11 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.viewpager2)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.photoview)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -246,9 +234,7 @@ dependencies {
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
// implementation(libs.reorderable)
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
@@ -287,6 +273,8 @@ kotlin {
"-progressive",
"-Xjvm-default=all",
"-Xcontext-receivers",
"-Xwhen-guards",
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
@@ -295,6 +283,7 @@ kotlin {
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
-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"
+8 -4
View File
@@ -44,8 +44,9 @@ set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}"
ExternalProject_Add(
nettle
PREFIX nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
GIT_TAG nettle_3.10_release_20240616
GIT_CONFIG core.autocrlf=input
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
INSTALL_COMMAND ""
@@ -89,7 +90,7 @@ set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.4
GIT_TAG v3.7.6
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
@@ -105,7 +106,10 @@ FetchContent_Declare(
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
endif ()
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
+38 -32
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;
}
-241
View File
@@ -1,241 +0,0 @@
#include <jni.h>
#include <math.h>
#include <stdbool.h>
#include <android/bitmap.h>
#include <stdlib.h>
#include <string.h>
/** A line will be considered as having content if 0.25% of it is filled. */
const float filledRatioLimit = 0.0025;
/** When the threshold is closer to 1, less content will be cropped. **/
#define THRESHOLD 0.75
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
static uint8_t inline grey(uint32_t pixel) {
const uint8_t r = ((uint8_t *) &pixel)[0];
const uint8_t g = ((uint8_t *) &pixel)[1];
const uint8_t b = ((uint8_t *) &pixel)[2];
return (r * 77 + g * 151 + b * 28) >> 8;
}
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) < thresholdForBlack;
}
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) > thresholdForWhite;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the left. */
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, 0, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, 0, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = 1; x < width; x++) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the right. */
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastX = width - 1;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, lastX, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, lastX, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return width;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = width - 2; x > 0; x--) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x + 1;
}
}
// No fill found... don't crop anything
return width;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the top. */
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, 0)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, 0)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = 1; y < height; y++) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the bottom. */
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastY = height - 1;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, lastY)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, lastY)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return height;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = height - 2; y > 0; y--) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y + 1;
}
}
// No fill found... don't crop anything
return height;
}
JNIEXPORT jintArray JNICALL
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
void *pixels = NULL;
AndroidBitmapInfo info;
memset(&info, 0, sizeof(info));
AndroidBitmap_getInfo(env, bitmap, &info);
AndroidBitmap_lockPixels(env, bitmap, &pixels);
uint32_t width = info.width;
uint32_t height = info.height;
uint32_t top = findBorderTop(pixels, width, height);
uint32_t bottom = findBorderBottom(pixels, width, height);
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
int array[4] = {left, top, right, bottom};
AndroidBitmap_unlockPixels(env, bitmap);
jintArray ret = (*env)->NewIntArray(env, 4);
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
return ret;
}
+9 -2
View File
@@ -1,6 +1,13 @@
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
if test -e $CI_NDK_LOCATION ;then
TOOLCHAIN=$CI_NDK_LOCATION
else
TOOLCHAIN=$ARCH_NDK_LOCATION
fi
TARGET=$2
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
AR=$TOOLCHAIN/llvm-ar \
CC="$TOOLCHAIN/clang -target $TARGET" \
RANLIB=$TOOLCHAIN/llvm-ranlib
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -365,25 +365,23 @@ internal class LazyGridScrollbarAdapter(
}
override fun averageVisibleLineSize(): Double {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
if (indexOfFirstKnownLineItem == -1) {
val visibleItemsInfo = // Non-exiting visible items
scrollState.layoutInfo.visibleItemsInfo.filter { it.line() != unknownLine }
if (visibleItemsInfo.isEmpty()) {
return 0.0
}
val reallyVisibleItemsInfo = // Non-exiting visible items
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
// Compute the size of the last line
val lastLine = reallyVisibleItemsInfo.last().line()
val lastLineSize = reallyVisibleItemsInfo
val last = visibleItemsInfo.last()
val lastLine = last.line()
val lastLineSize = visibleItemsInfo
.asReversed()
.asSequence()
.takeWhile { it.line() == lastLine }
.maxOf { it.mainAxisSize() }
val first = reallyVisibleItemsInfo.first()
val last = reallyVisibleItemsInfo.last()
val lineCount = last.line() - first.line() + 1
val first = visibleItemsInfo.first()
val lineCount = lastLine - first.line() + 1
val lineSpacingSum = (lineCount - 1) * lineSpacing
return (
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
@@ -76,13 +76,11 @@ fun SearchBarInputField(
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading -> { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } } },
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithLabel(),
container = {},
)
},
@@ -102,5 +100,4 @@ fun SearchBarInputField(
val SearchBarIconOffsetX = 4.dp
val SearchBarMinWidth = 360.dp
val SearchBarMaxWidth = 720.dp
val SearchBarHorizontalPadding = 16.dp
const val AnimationDelayMillis = 100
@@ -69,6 +69,11 @@ interface SystemUiController {
isStatusBarVisible = value
isNavigationBarVisible = value
}
/**
* Property which holds whether the status bar icons + content are 'dark' or not.
*/
var statusBarDarkContentEnabled: Boolean
}
/**
@@ -149,4 +154,10 @@ internal class AndroidSystemUiController(
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
}
}
override var statusBarDarkContentEnabled: Boolean
get() = windowInsetsController?.isAppearanceLightStatusBars == true
set(value) {
windowInsetsController?.isAppearanceLightStatusBars = value
}
}
@@ -22,7 +22,6 @@ import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -30,7 +29,7 @@ import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
@@ -40,9 +39,12 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DetectBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.QrCodeInterceptor
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
@@ -55,6 +57,7 @@ import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.tools.dataStateFlow
import com.hippo.ehviewer.ui.tools.initSETConnection
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
@@ -67,6 +70,7 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.plugins.cookies.HttpCookies
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
import logcat.LogPriority
@@ -83,6 +87,7 @@ class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() {
initSETConnection()
// Initialize Settings on first access
lifecycleScope.launchIO {
val mode = Settings.theme
@@ -136,9 +141,6 @@ class EhApplication :
}
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat(LogPriority.VERBOSE) { anies.toString() }
}
}
}
@@ -167,6 +169,7 @@ class EhApplication :
}
override fun newImageLoader(context: Context) = context.imageLoader {
interceptorCoroutineContext(Dispatchers.Default)
components {
serviceLoaderEnabled(false)
add(KtorNetworkFetcherFactory { ktorClient })
@@ -176,6 +179,9 @@ class EhApplication :
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor)
add(DetectBorderInterceptor)
add(QrCodeInterceptor)
add(MapExtraInfoInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -150,7 +150,7 @@ object EhDB {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insert(artists)
dao.insertOrIgnore(artists)
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_MOBILE_USER_AGENT
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
@@ -123,7 +124,7 @@ object Settings : DataStorePreferences(null) {
val security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -142,7 +143,6 @@ object Settings : DataStorePreferences(null) {
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var forceEhThumb by boolPref("force_eh_thumb", false)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
var hideHvEvents by boolPref("hide_hv_events", false)
@@ -163,6 +163,7 @@ object Settings : DataStorePreferences(null) {
var downloadOriginImage by boolPref("download_origin_image", false)
var saveAsCbz by boolPref("save_as_cbz", false)
var archiveMetadata by boolPref("archive_metadata", true)
var desktopSite by boolPref("desktop_site", false)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
@@ -171,13 +172,10 @@ object Settings : DataStorePreferences(null) {
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
// Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
@@ -209,6 +207,12 @@ object Settings : DataStorePreferences(null) {
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
val userAgent
get() = if (desktopSite) CHROME_USER_AGENT else CHROME_MOBILE_USER_AGENT
init {
if ("CN" == Locale.getDefault().country) {
@@ -216,6 +220,9 @@ object Settings : DataStorePreferences(null) {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
}
}
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
recentDownloadLabel.value = ""
}
}
interface Delegate<R> {
@@ -15,32 +15,35 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
// ExHentai Large Preview: https://s.exhentai.org/t/***
// ExHentai Normal Preview: https://s.exhentai.org/m/***
// EHentai Normal Preview: https://ehgt.org/m/***
// EHentai Large Preview: https://ehgt.org/***
// E-Hentai Large Preview: https://ehgt.org/***
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
private const val NORMAL_PREVIEW_PREFIX = "$"
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
val GalleryPreview.url
get() = if (exThumb) {
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
val String.isNormalPreviewKey
get() = startsWith(NORMAL_PREVIEW_PREFIX)
val GalleryPreview.imageKey
get() = if (this is NormalGalleryPreview) {
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
} else {
URL_PREFIX_THUMB_E
} + imageKey
getThumbKey(url)
}
val GalleryInfo.thumbUrl
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
private val thumbPrefix
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
@@ -29,6 +29,7 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
@@ -55,13 +56,15 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.readAvailable
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
@@ -96,7 +99,7 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
}
}
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
@@ -124,14 +127,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
}
// Check bad response code
if (code >= 400) {
throw StatusCodeException(code)
}
status.ensureSuccess()
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
"No hits found!" -> throw NoHitsFoundException()
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
@@ -151,7 +152,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
rethrowExactly(response.status, body.left(), it)
}.getOrThrow()
}
@@ -163,7 +164,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response.status.value, buffer.right(), it)
rethrowExactly(response.status, buffer.right(), it)
}.getOrThrow()
}
}
@@ -333,7 +334,7 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
append("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Quota")
}
}.fetchUsingAsText(HomeParser::parseResetLimits)
@@ -20,6 +20,7 @@ import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormDataContent
@@ -45,13 +46,18 @@ import okio.BufferedSource
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text().parseAs<T>()
}
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
@@ -24,14 +24,13 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import com.hippo.ehviewer.util.ensureSuccess
import com.hippo.ehviewer.util.sha1
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
@@ -143,11 +142,8 @@ object EhTagDatabase : CoroutineScope {
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
client.prepareGet(url).executeSafely {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
throw StatusCodeException(it.status.value)
}
it.status.ensureSuccess()
it.bodyAsChannel().copyTo(file)
}
}.onFailure {
file.delete()
@@ -60,14 +60,10 @@ object EhUrl {
}
val apiUrl: String
get() = if (Settings.forceEhThumb) {
API_E
} else {
when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
get() = when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
val referer: String
@@ -172,10 +172,7 @@ object EhUtils {
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
fun handleThumbUrlResolution(url: String?): String? {
if (null == url) {
return null
}
fun handleThumbUrlResolution(url: String): String {
val resolution = when (Settings.thumbResolution) {
0 -> return url
1 -> "250"
@@ -141,3 +141,6 @@ fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
val GalleryInfo.hasAds
get() = simpleTags?.any { "extraneous ads" in it } ?: false
@@ -19,19 +19,19 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
interface GalleryPreview : Parcelable {
val imageKey: String
val url: String
val position: Int
}
@Parcelize
data class LargeGalleryPreview(
override val imageKey: String,
override val url: String,
override val position: Int,
) : GalleryPreview
@Parcelize
data class NormalGalleryPreview(
override val imageKey: String,
override val url: String,
override val position: Int,
val offsetX: Int,
val clipWidth: Int,
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
@@ -16,7 +16,6 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.parseAs
@@ -32,7 +31,7 @@ object GalleryApiParser {
title = item.title.unescapeXml()
titleJpn = item.titleJpn.unescapeXml()
category = getCategory(item.category)
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
thumbKey = getThumbKey(item.thumb)
uploader = item.uploader?.unescapeXml()
posted = ParserUtils.formatDate(item.posted * 1000)
rating = item.rating
@@ -21,7 +21,6 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
@@ -62,7 +61,7 @@ object GalleryDetailParser {
RegexOption.DOT_MATCHES_ALL,
)
private val PATTERN_TORRENT =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
private val PATTERN_ARCHIVE =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
@@ -140,11 +139,7 @@ object GalleryDetailParser {
val gm = d.getElementsByClass("gm")[0]
// Thumb url
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
gd.thumbKey = getThumbKey(
PATTERN_COVER.find(it)?.run {
handleThumbUrlResolution(groupValues[3])
}!!,
)
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
}
gd.title = gm.getElementById("gn")?.text()?.trim()
@@ -458,9 +453,9 @@ object GalleryDetailParser {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
val position = it.groupValues[2].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val url = it.groupValues[3].trim()
val pageUrl = it.groupValues[1].trim()
LargeGalleryPreview(imageKey, position) to pageUrl
LargeGalleryPreview(url, position) to pageUrl
}.run {
first.toList() to second.toList()
}
@@ -470,12 +465,12 @@ object GalleryDetailParser {
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
val position = it.groupValues[6].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val url = it.groupValues[3].trim()
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
val width = it.groupValues[1].toInt()
val height = it.groupValues[2].toInt()
val pageUrl = it.groupValues[5].trim()
NormalGalleryPreview(imageKey, position, xOffset, width, height) to pageUrl
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
}.run {
first.toList() to second.toList()
}
@@ -19,22 +19,18 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.ExceptionUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
object GalleryMultiPageViewerPTokenParser {
private const val IMAGE_LIST_STRING = "var imagelist = "
fun parse(body: String): List<String> {
fun parse(body: String): List<String> = runCatching {
val index = body.indexOf(IMAGE_LIST_STRING)
val imagelist = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index))
return runCatching {
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse pToken from MPV error", it)
}
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
throw ParseException("Parse pToken from MPV error", it)
}
@Serializable
@@ -1,55 +1,58 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.getExtra
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toIntRect
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.resourceScope
import coil3.asImage
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.image.copyBitmapToAHB
import com.hippo.ehviewer.util.isAtLeastQ
import eu.kanade.tachiyomi.util.system.logcat
import moe.tarsin.coroutines.runSuspendCatching
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
@RequiresApi(Build.VERSION_CODES.O)
private const val FORMAT = HardwareBuffer.RGBA_8888
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
class BitmapImageWithRect(
val rect: IntRect,
val image: BitmapImage,
) : Image by image
@RequiresApi(Build.VERSION_CODES.O)
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
if (image is BitmapImageWithExtraInfo) {
// Copy with cropped region
val srcSize = IntSize(image.width, image.height)
if (image.rect.size != srcSize) {
val (x, y) = image.rect.topLeft
val (w, h) = image.rect.size
val src = image.image.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
val bitmap = when {
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
resourceScope {
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
copyBitmapToAHB(src, buffer, x, y)
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
}
}.onFailure { logcat(it) }.getOrNull()
else -> null
} ?: Bitmap.createBitmap(src, x, y, w, h)
src.recycle()
return result.copy(image = image.copy(image = bitmap.asImage(), rect = image.rect.size.toIntRect()))
}
}
}
@@ -0,0 +1,51 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
object DetectBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo && !image.hasQrCode) {
val bitmap = image.image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = image.copy(rect = rect)
return result.copy(image = new)
}
}
}
}
return result
}
}
@@ -7,9 +7,9 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.files.isDirectory
import com.hippo.files.isFile
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
private const val THUMB_FILE = "thumb.jpg"
@@ -18,22 +18,20 @@ object DownloadThumbInterceptor : Interceptor {
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
if (withIOContext { thumb.isFile }) {
val thumb = downloadLocation / location / THUMB_FILE
if (thumb.isFile) {
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
}
val new = chain.request.newBuilder().data(url).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) {
withIOContext {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
if (result is SuccessResult && thumb.parent?.isDirectory == true) {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
}
return result
@@ -16,7 +16,7 @@ import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.url
import com.hippo.ehviewer.client.imageKey
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
@@ -3,8 +3,6 @@ package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
@@ -30,25 +28,18 @@ object HardwareBitmapInterceptor : Interceptor {
val result = chain.proceed()
val request = result.request
if (!request.allowHardware && result is SuccessResult) {
var rect: IntRect? = null
val image = result.image
val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
if (image is BitmapImageWithExtraInfo) {
val bitmap = image.image.bitmap
val isHardware = bitmap.config == Bitmap.Config.HARDWARE
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (!isHardware && maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
return result.copy(image = image.copy(image = hwBitmap.asImage()))
}
}
}
}
@@ -0,0 +1,29 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Image
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
data class BitmapImageWithExtraInfo(
val image: BitmapImage,
val rect: IntRect = IntRect(0, 0, image.width, image.height),
val hasQrCode: Boolean = false,
) : Image by image
object MapExtraInfoInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
val needMap = with(chain.request) { maybeCropBorder || detectQrCode || !allowHardware }
if (needMap && result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
return result.copy(image = BitmapImageWithExtraInfo(image))
}
}
return result
}
}
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.isNormalPreviewKey
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CancellableContinuation
@@ -12,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
object MergeInterceptor : Interceptor {
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
@@ -24,7 +24,9 @@ object MergeInterceptor : Interceptor {
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
// Wake up a pending continuation to continue executing task
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply { resume(Unit) { triggerSuccessor(key) } }
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply {
resume(Unit) { _, _, _ -> triggerSuccessor(key) }
}
// If no successor, delete this entry from hashmap
successor ?: pendingContinuationMap.remove(key)
}
@@ -41,14 +43,14 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return chain.proceed()
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
val existPendingContinuations = pendingContinuationMap[key]
if (existPendingContinuations == null) {
pendingContinuationMap[key] = EMPTY_LIST
continuation.resume(Unit) {
continuation.resume(Unit) { _, _, _ ->
triggerSuccessor(key)
}
} else {
@@ -62,7 +64,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
return chain.proceed().apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -0,0 +1,33 @@
package com.hippo.ehviewer.coil
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.hasQrCode
private val detectQrCodeKey = Extras.Key(default = false)
fun ImageRequest.Builder.detectQrCode(enable: Boolean) = apply {
extras[detectQrCodeKey] = enable
}
val ImageRequest.detectQrCode: Boolean
get() = getExtra(detectQrCodeKey)
object QrCodeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.detectQrCode && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo) {
val hasQrCode = hasQrCode(image.image.bitmap)
val new = image.copy(hasQrCode = hasQrCode)
return result.copy(image = new)
}
}
return result
}
}
@@ -2,12 +2,13 @@ package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DownloadArtistDao {
@Insert
suspend fun insert(downloadArtists: List<DownloadArtist>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(downloadArtists: List<DownloadArtist>)
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
suspend fun deleteByGid(gid: Long)
@@ -10,7 +10,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Dao
interface HistoryDao {
@Query("SELECT * FROM HISTORY ORDER BY TIME")
@Query("SELECT HISTORY.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<HistoryInfo>
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -14,7 +14,7 @@ interface LocalFavoritesDao {
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
fun count(): Flow<Int>
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
@Query("SELECT LOCAL_FAVORITES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<LocalFavoriteInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
suspend fun random(): BaseGalleryInfo
suspend fun random(): BaseGalleryInfo?
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
suspend fun contains(gid: Long): Boolean
@@ -16,6 +16,7 @@
package com.hippo.ehviewer.gallery
import android.os.ParcelFileDescriptor
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ByteBufferSource
import com.hippo.ehviewer.image.Image
@@ -35,6 +36,7 @@ import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
@@ -51,24 +53,23 @@ class ArchivePageLoader(
private val file: Path,
gid: Long = 0,
startPage: Int = 0,
private val hasAds: Boolean = false,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) {
private val hostJob = async(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size == 0) {
return@launch
}
if (passwdProvider != null && needPassword()) {
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size != 0 && passwdProvider != null && needPassword()) {
archivePasswds?.forEach {
it ?: return@forEach
if (providePassword(it)) return@launch
if (providePassword(it)) return@async size
}
val toAdd = passwdProvider { providePassword(it) }
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
}
size
}
override var size = 0
@@ -105,6 +106,8 @@ class ArchivePageLoader(
}
}
private fun mayBeAd(index: Int) = index > size - 10
private suspend fun doRealWork(index: Int) {
val buffer = extractToByteBuffer(index) ?: return notifyPageFailed(index, null)
check(buffer.isDirect)
@@ -121,7 +124,7 @@ class ArchivePageLoader(
src.close()
throw it
}
val image = Image.decode(src) ?: return notifyPageFailed(index, null)
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index)) ?: return notifyPageFailed(index, null)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
@@ -136,17 +139,13 @@ class ArchivePageLoader(
}
override suspend fun awaitReady(): Boolean {
hostJob.join()
return super.awaitReady() && size != 0
size = hostJob.await()
return super.awaitReady() && isReady
}
override val isReady: Boolean
get() = size != 0
override fun onCancelRequest(index: Int) {
mJobMap[index]?.cancel()
}
override val title by lazy {
FileUtils.getNameFromFilename(file.name)!!
}
@@ -162,7 +161,7 @@ class ArchivePageLoader(
false
}
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
}
private const val DEBUG_TAG = "ArchivePageLoader"
@@ -66,10 +66,6 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
override fun onCancelRequest(index: Int) {
mSpiderQueen.cancelRequest(index)
}
override fun onGetPages(pages: Int) {}
override fun onGet509(index: Int) {}
@@ -105,7 +101,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
notifyPageFailed(index, error)
}
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, pair)
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, bounds)
}
}
@@ -0,0 +1,43 @@
package com.hippo.ehviewer.gallery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import com.hippo.ehviewer.image.Image
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class Page(
val index: Int,
val statusFlow: MutableStateFlow<PageStatus> = MutableStateFlow(PageStatus.Queued),
)
fun Page.unblock() = statusFlow.update { status ->
when (status) {
is PageStatus.Blocked -> PageStatus.Ready(status.ad)
else -> error("Call unblock on page not blocked!!!")
}
}
fun Page.reset() = statusFlow.update { PageStatus.Queued }
val Page.status
get() = statusFlow.value
val Page.statusObserved
@Composable
get() = statusFlow.collectAsState().value
val PageStatus.progressObserved
@Composable
get() = when (this) {
is PageStatus.Loading -> progress.collectAsState().value
else -> 0f
}
sealed interface PageStatus {
data object Queued : PageStatus
data class Blocked(val ad: Image) : PageStatus
data class Ready(val image: Image) : PageStatus
data class Error(val message: String?) : PageStatus
data class Loading(val progress: MutableStateFlow<Float>) : PageStatus
}
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.gallery
import androidx.annotation.CallSuper
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okio.Path
@@ -31,21 +31,19 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) :
PageLoader(),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private val progressJob = launch(start = CoroutineStart.LAZY) {
private val progressJob = async(start = CoroutineStart.LAZY) {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun start() {
super.start()
progressJob.start()
}
@CallSuper
override suspend fun awaitReady(): Boolean {
progressJob.join()
return startPage != -1
progressJob.await()
return super.awaitReady() && startPage != -1
}
override fun stop() {
@@ -18,8 +18,8 @@
package com.hippo.ehviewer.image
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import androidx.compose.ui.unit.IntRect
import android.hardware.HardwareBuffer
import androidx.compose.ui.unit.IntSize
import arrow.core.Either
import arrow.core.left
import arrow.core.right
@@ -34,7 +34,8 @@ import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
import com.hippo.ehviewer.coil.detectQrCode
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif
@@ -51,26 +52,25 @@ import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
val size = image.size
val rect = when (image) {
is BitmapImageWithRect -> image.rect
else -> image.run { IntRect(0, 0, width, height) }
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val intrinsicSize = with(image) { IntSize(width, height) }
val allocationSize = image.size
val hasQrCode = when (image) {
is BitmapImageWithExtraInfo -> image.hasQrCode
else -> false
}
var innerImage: CoilImage? = when (image) {
is BitmapImageWithRect -> image.image
is BitmapImageWithExtraInfo -> image.image
else -> image
}
var isRecyclable = true
@Synchronized
fun recycle() {
when (val image = innerImage ?: return) {
is DrawableImage -> {
(image.drawable as Animatable).stop()
image.drawable.callback = null
src.close()
}
is DrawableImage -> src.close()
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
@@ -79,7 +79,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.toUri()) }
@@ -88,6 +88,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
detectQrCode(checkExtraneousAds)
memoryCachePolicy(CachePolicy.DISABLED)
}
return when (val result = appCtx.imageLoader.execute(req)) {
@@ -96,7 +97,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
}
}
suspend fun decode(src: ImageSource): Image? {
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image? {
return runCatching {
val image = when (src) {
is PathSource -> {
@@ -112,18 +113,18 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
src.close()
}
}
return decode(source)
return decode(source, checkExtraneousAds)
}
}
}
src.right().decodeCoil()
src.right().decodeCoil(checkExtraneousAds)
}
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
src.left().decodeCoil()
src.left().decodeCoil(checkExtraneousAds)
}
}
Image(image, src).apply {
@@ -149,3 +150,5 @@ interface ByteBufferSource : ImageSource {
}
external fun detectBorder(bitmap: Bitmap): IntArray
external fun hasQrCode(bitmap: Bitmap): Boolean
external fun copyBitmapToAHB(src: Bitmap, dst: HardwareBuffer, x: Int, y: Int)
@@ -10,4 +10,4 @@ external fun getExtension(index: Int): String
external fun needPassword(): Boolean
external fun providePassword(str: String): Boolean
external fun closeArchive()
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int): Boolean
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int)
@@ -15,6 +15,7 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
@@ -15,7 +15,6 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.XmlUtilInternal
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML
@@ -26,8 +25,6 @@ import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml"
private const val TAG_ORIGINAL = "original"
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
@OptIn(XmlUtilInternal::class)
private val xml = XML {
recommended {
ignoreUnknownChildren()
@@ -63,6 +60,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
else -> simpleTags?.forEach { tagString ->
val (namespace, tag) = tagString.split(':', limit = 2)
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
when (val ns = TagNamespace(namespace)) {
Artist, Cosplayer -> artists.add(tag)
Group -> groups.add(tag)
@@ -46,6 +46,8 @@ import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.mkdirs
import com.hippo.files.moveTo
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
@@ -54,7 +56,7 @@ import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.request
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo
import io.ktor.utils.io.copyTo
import kotlin.io.path.readText
import kotlinx.coroutines.CancellationException
import okio.Path
@@ -90,10 +92,10 @@ class SpiderDen(val info: GalleryInfo) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir!!
tempDownloadDir = info.tempDownloadDir!!.apply { mkdirs() }
}
}
}
@@ -103,9 +105,11 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.read(key) { true } ?: false
}
private fun findImageFile(index: Int) = synchronized(fileCache) {
private fun findImageFile(index: Int, temp: Boolean = false) = synchronized(fileCache) {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
fileCache.entries.firstOrNull { (name) ->
name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX)
}?.value
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
@@ -144,14 +148,17 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.run {
delete()
synchronized(fileCache) {
fileCache.remove(name)
}
}
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
fun removeIntermediateFiles(index: Int) {
removeFromCache(index)
removeTempFile(index)
}
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
val name = perFilename(index, extension)
@@ -185,7 +192,15 @@ class SpiderDen(val info: GalleryInfo) {
crossinline fops: suspend (Path) -> Unit,
): Boolean {
imageDir?.run {
fops(findDownloadFileForIndex(index, ext))
val tempFile = findDownloadFileForIndex(index, "$ext$TEMP_SUFFIX")
fops(tempFile)
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
file.delete()
tempFile.moveTo(file)
synchronized(fileCache) {
fileCache.remove(tempFile.name)
fileCache[file.name] = file
}
return true
}
@@ -328,7 +343,7 @@ class SpiderDen(val info: GalleryInfo) {
}
suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
@@ -353,6 +368,7 @@ class SpiderDen(val info: GalleryInfo) {
}
}
private const val TEMP_SUFFIX = ".tmp"
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
@@ -28,6 +28,7 @@ import com.hippo.ehviewer.client.EhUrl.getGalleryDetailUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.hasAds
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
import com.hippo.ehviewer.client.fetchUsingAsText
@@ -50,6 +51,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -224,7 +226,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
}
private val prepareJob = launch { doPrepare() }
private val prepareJob = async { doPrepare() }
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
private suspend fun doPrepare() {
@@ -235,7 +237,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun awaitReady(): Boolean {
prepareJob.join()
prepareJob.await()
return isReady
}
@@ -283,10 +285,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
fun cancelRequest(index: Int) {
mWorkerScope.cancelDecode(index)
}
fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
mWorkerScope.updateRAList(pages, pair)
}
@@ -506,15 +504,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val showKeyLock = Mutex()
private val mDownloadDelay = Settings.downloadDelay.milliseconds
private val downloadTimeout = Settings.downloadTimeout.seconds
private val delayLock = Mutex()
private var lastRequestTime = TimeSource.Monotonic.markNow()
var isDownloadMode = false
private set
fun cancelDecode(index: Int) {
decoder.cancel(index)
}
@Synchronized
fun enterDownloadMode() {
if (isDownloadMode) return
@@ -540,20 +533,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
val state = mPageStateArray[index]
if (!force) {
if (state == STATE_FINISHED) return
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
}
if (!force && state == STATE_FINISHED) return
val currentJob = mFetcherJobMap[index]
val skipHath = force && !orgImg && currentJob?.isActive == true
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
if (currentJob?.isActive != true) {
mFetcherJobMap[index] = launch {
runCatching {
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
mSemaphore.withPermit {
doInJob(index, force, orgImg, skipHath)
}
@@ -590,7 +576,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
?: getPTokenFromInternet(index)
?: getPTokenFromMultiPageViewer(index)
}
updatePageState(index, STATE_DOWNLOADING)
val previousPToken: String?
val pToken: String
pTokenLock.withLock {
@@ -598,13 +583,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
mSpiderInfo.pTokenMap.remove(index)
}
} else if (index in mSpiderDen) {
return updatePageState(index, STATE_FINISHED)
}
pToken = getPToken(index)
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
previousPToken = getPToken(index - 1)
// The lock for delay should be acquired before anything else to maintain FIFO order
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
updatePageState(index, STATE_DOWNLOADING)
var skipHathKey: String? = null
var originImageUrl: String? = null
@@ -693,7 +685,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
updatePageState(index, STATE_FINISHED)
return
}.onFailure {
mSpiderDen.remove(index)
mSpiderDen.removeIntermediateFiles(index)
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
error = when (it) {
is TimeoutCancellationException -> ERROR_TIMEOUT
@@ -748,9 +740,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private fun mayBeAd(index: Int) = index > size - 10
private val hasAds = galleryInfo.hasAds
private suspend fun doInJob(index: Int) {
val src = mSpiderDen.getImageSource(index) ?: return
val image = Image.decode(src)
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index))
checkNotNull(image)
runCatching {
currentCoroutineContext().ensureActive()
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,6 +55,7 @@ import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter
@@ -68,7 +70,6 @@ import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.sync.Mutex
@@ -280,21 +281,14 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
context (Context, DestinationsNavigator)
fun navToReader(
info: BaseGalleryInfo,
page: Int = -1,
) {
if (Settings.newReader) {
navigate(ReaderScreenDestination(info, page))
} else {
val intent = Intent(this@Context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_EH
intent.putExtra(ReaderActivity.KEY_GALLERY_INFO, info)
intent.putExtra(ReaderActivity.KEY_PAGE, page)
startActivity(intent)
}
}
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(uri: Uri) =
navToReader(ReaderScreenArgs.Archive(uri))
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(DialogState, Context, DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
@@ -1,25 +1,37 @@
package com.hippo.ehviewer.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.togetherWith
import com.hippo.ehviewer.util.findActivity
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.CoroutineScope
@Composable
inline fun <R> composing(
inline fun <R> AnimatedVisibilityScope.composing(
navigator: DestinationsNavigator,
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
() -> R,
) = block(
LocalContext.current.run { remember { findActivity<MainActivity>() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
navigator,
rememberCoroutineScope(),
)
) = with(NoopTransitionsVisibilityScope) {
togetherWith(implicit<AnimatedVisibilityScope>()) {
block(
with(LocalContext.current) { remember { findActivity() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
LocalSharedTransitionScope.current,
this,
navigator,
rememberCoroutineScope(),
)
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf(
R.string.favorite_name to favoriteName,
)
context(Context, DestinationsNavigator)
@Composable
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
Column(modifier = Modifier.fillMaxWidth()) {
val context = LocalContext.current
Text(
text = stringResource(id = R.string.gallery_info),
modifier = Modifier.align(Alignment.CenterHorizontally),
@@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
if (index == INDEX_PARENT) {
if (content != null) {
navigator.navWithUrl(content)
navWithUrl(content)
}
} else {
context.addTextToClipboard(content, true)
addTextToClipboard(content, true)
if (index == INDEX_URL) {
// Save it to avoid detect the gallery
Settings.clipboardTextHashCode = data[index].hashCode()
@@ -17,6 +17,8 @@ package com.hippo.ehviewer.ui
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ContentResolver.SCHEME_CONTENT
import android.content.ContentResolver.SCHEME_FILE
import android.content.Intent
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
@@ -24,6 +26,7 @@ import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -134,6 +137,7 @@ import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.NoopSharedTransitionScope
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
@@ -147,7 +151,7 @@ import com.hippo.ehviewer.util.sha1
import com.hippo.files.isDirectory
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.spec.Direction
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -162,7 +166,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
private val navItems = arrayOf<Triple<Direction, Int, ImageVector>>(
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
@@ -230,7 +234,7 @@ class MainActivity : EhActivity() {
suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.waring,
showCancelButton = false,
@@ -244,18 +248,24 @@ class MainActivity : EhActivity() {
}
}
val hasNetwork = remember { connectivityManager.activeNetwork != null }
if (!AppConfig.isBenchmark) {
val noNetwork = stringResource(R.string.no_network)
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
if (hasNetwork) {
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
} else {
snackbarState.showSnackbar(noNetwork)
}
}
}
@@ -265,10 +275,19 @@ class MainActivity : EhActivity() {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
val uri = intent.data ?: return@collect
when (uri.scheme) {
SCHEME_CONTENT, SCHEME_FILE -> {
navigator.navToReader(uri)
}
else -> {
val url = uri.toString()
if (!navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
}
}
}
Intent.ACTION_SEND -> {
@@ -450,12 +469,22 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
// https://issuetracker.google.com/336140982
// SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
val start = when {
Settings.needSignIn -> SignInScreenDestination
hasNetwork -> StartDestination
else -> DownloadsScreenDestination
}
DestinationsNavHost(
navGraph = NavGraphs.root,
start = start,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
// }
}
}
}
@@ -477,7 +506,7 @@ class MainActivity : EhActivity() {
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
var checked by mutableStateOf(false)
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.app_link_not_verified_title,
onCancelButtonClick = {
@@ -508,7 +537,7 @@ class MainActivity : EhActivity() {
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
} catch (_: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
@@ -537,6 +566,7 @@ val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionL
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope not present!") }
@Composable
fun DrawerHandle(enabled: Boolean) {
@@ -5,10 +5,10 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.navigation.NavBackStackEntry
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.DialogState
@@ -19,20 +19,8 @@ import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
dialogState.Intercept()
@@ -45,15 +33,17 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
}
}
private typealias Ty = AnimatedContentTransitionScope<NavBackStackEntry>
@Composable
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
val slideDistance = rememberSlideDistance()
return remember(slideDistance) {
object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
override val enterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
}
}
}
@@ -7,23 +7,27 @@ import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.crossfade
import coil3.size.Size
import com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
private val textView: TextView,
) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
textView.context.imageLoader.enqueue(
textView.context.imageRequest {
data(source)
crossfade(false)
target { drawable ->
setDrawable(drawable.asDrawable(textView.resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
with(textView.context) {
imageLoader.enqueue(
imageRequest {
data(source)
crossfade(false)
size(Size.ORIGINAL)
target { drawable ->
setDrawable(drawable.asDrawable(resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
}
}
}
@@ -116,7 +116,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}.onFailure {
withUIContext {
focusManager.clearFocus()
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.get_it,
title = R.string.sign_in_failed,
showCancelButton = false,
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -43,6 +44,8 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.listThumbGenerator
import com.hippo.ehviewer.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
@@ -90,6 +93,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun DownloadCard(
onClick: () -> Unit,
@@ -105,10 +109,12 @@ fun DownloadCard(
Row {
val thumb = remember {
movableContentOf<DownloadInfo> {
EhAsyncCropThumb(
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
with(listThumbGenerator) {
EhAsyncCropThumb(
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
}
}
if (selectMode) {
@@ -1,5 +1,7 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ShapeDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
@@ -7,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.Image
@@ -14,6 +17,8 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.SETNodeGenerator
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.shouldCrop
@Composable
@@ -23,6 +28,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
return remember(model) { context.imageRequest(model) }
}
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
@Composable
@NonRestartableComposable
fun EhAsyncThumb(
@@ -33,13 +39,16 @@ fun EhAsyncThumb(
) = AsyncImage(
model = requestOf(model),
contentDescription = null,
modifier = modifier,
modifier = modifier
// .sharedBounds(key = "${model.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = onSuccess?.let { callback ->
{ callback(it.result.image) }
},
contentScale = contentScale,
)
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
@Composable
fun EhAsyncCropThumb(
key: GalleryInfo,
@@ -49,7 +58,9 @@ fun EhAsyncCropThumb(
AsyncImage(
model = requestOf(key),
contentDescription = null,
modifier = modifier,
modifier = modifier
// .sharedBounds(key = "${key.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
@@ -36,6 +36,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.detailThumbGenerator
@Composable
fun GalleryDetailHeaderInfoCard(
@@ -96,6 +98,7 @@ fun GalleryDetailHeaderInfoCard(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryDetailHeaderCard(
info: GalleryInfo,
@@ -107,10 +110,15 @@ fun GalleryDetailHeaderCard(
) = ElevatedCard(modifier = modifier) {
Row {
Card {
EhAsyncCropThumb(
key = remember(info.gid) { info },
modifier = Modifier.height(dimensionResource(id = R.dimen.gallery_detail_thumb_height)).width(dimensionResource(id = R.dimen.gallery_detail_thumb_width)),
)
with(detailThumbGenerator) {
EhAsyncCropThumb(
key = remember(info.gid) { info },
modifier = Modifier.size(
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
),
)
}
}
Spacer(modifier = Modifier.weight(0.5F))
Column(
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -51,6 +52,8 @@ import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.ElevatedCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.listThumbGenerator
import com.hippo.ehviewer.util.FavouriteStatusRouter
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
@@ -88,6 +91,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoListItem(
onClick: () -> Unit,
@@ -105,10 +109,12 @@ fun GalleryInfoListItem(
) {
Row {
Card {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
with(listThumbGenerator) {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
}
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
@@ -199,6 +205,7 @@ fun GalleryInfoListItem(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoGridItem(
onClick: () -> Unit,
@@ -223,13 +230,15 @@ fun GalleryInfoGridItem(
}
mutableFloatStateOf(ratio)
}
EhAsyncThumb(
model = info,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
with(listThumbGenerator) {
EhAsyncThumb(
model = info,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
}
val categoryColor = EhUtils.getCategoryColor(info.category)
Badge(
modifier = Modifier.align(Alignment.TopEnd).widthIn(min = 32.dp).height(24.dp),
@@ -53,6 +53,7 @@ import androidx.paging.compose.itemKey
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
@@ -116,6 +117,11 @@ fun GalleryList(
}
},
) {
val showLoadStateIndicator = when (val state = data.loadState.append) {
LoadState.Loading -> true
is LoadState.Error -> state.error !is NoHitsFoundException
is LoadState.NotLoading -> false
}
if (listMode == 0) {
val columnWidth by collectDetailSizeAsState()
FastScrollLazyVerticalGrid(
@@ -136,7 +142,7 @@ fun GalleryList(
detailItemContent(info)
}
}
if (data.loadState.append !is LoadState.NotLoading) {
if (showLoadStateIndicator) {
item(span = { GridItemSpan(maxLineSpan) }) {
LoadStateIndicator(state = data.loadState.append) {
data.retry()
@@ -165,7 +171,7 @@ fun GalleryList(
thumbItemContent(info)
}
}
if (data.loadState.append !is LoadState.NotLoading) {
if (showLoadStateIndicator) {
item(span = StaggeredGridItemSpan.FullLine) {
LoadStateIndicator(state = data.loadState.append) {
data.retry()
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -26,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@@ -151,23 +153,27 @@ fun SearchFilter(
val (from, to) = dialogState.awaitResult(
initial = advancedOption.fromPage to advancedOption.toPage,
title = R.string.key_pages,
invalidator = { (min, _max) ->
// 0 means max pages not filled, though +inf
val max = if (_max == 0) Int.MAX_VALUE else _max
if (min != 0) {
ensure(max - min >= 20) { pageErr2 }
} else {
ensure(max >= 10) { pageErr1 }
invalidator = { (min, max) ->
if (max != 0) {
if (min != 0) {
ensure(min <= (max / 2).coerceAtMost(max - 20)) { pageErr2 }
} else {
ensure(max >= 10) { pageErr1 }
}
}
},
) { error ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceIn(0, 1000))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
@@ -178,7 +184,7 @@ fun SearchFilter(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceIn(0, 2000))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
@@ -188,11 +194,14 @@ fun SearchFilter(
}
if (error != null) {
ListItem(
headlineContent = { Text(text = error) },
headlineContent = {
Text(text = error, style = MaterialTheme.typography.bodySmall)
},
leadingContent = {
Icon(imageVector = Icons.Default.Info, contentDescription = null)
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error,
),
@@ -0,0 +1,34 @@
package com.hippo.ehviewer.ui.reader
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toAndroidRectF
import androidx.compose.ui.util.fastRoundToInt
class BitmapPainter(private val bitmap: Bitmap, override val intrinsicSize: Size) : Painter() {
private val srcRect = intrinsicSize.toRect().toAndroidRectF()
private val dstRect = RectF()
private val matrix = Matrix()
// Use the overload that takes a `Matrix` to bypass the 100 MB size limit
override fun DrawScope.onDraw() = drawIntoCanvas { canvas ->
dstRect.right = size.width.fastRoundToInt().toFloat()
dstRect.bottom = size.height.fastRoundToInt().toFloat()
matrix.setRectToRect(srcRect, dstRect, Matrix.ScaleToFit.FILL)
canvas.nativeCanvas.drawBitmap(bitmap, matrix, paint)
}
}
private val paint = Paint().apply {
isAntiAlias = true
isFilterBitmap = true
isDither = true
}
@@ -0,0 +1,23 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.ui.geometry.Offset
import com.hippo.ehviewer.Settings
import me.saket.telephoto.zoomable.DoubleClickToZoomListener
import me.saket.telephoto.zoomable.ZoomableState
object DoubleTapZoom : DoubleClickToZoomListener {
override suspend fun onDoubleClick(state: ZoomableState, centroid: Offset) {
if (Settings.doubleTapToZoom.value) {
val zoomFraction = state.zoomFraction ?: return // Content isn't ready yet
if (zoomFraction > 0.05f) {
state.resetZoom()
} else {
// Workaround for https://github.com/saket/telephoto/issues/45
state.zoomTo(
zoomFactor = state.contentTransformation.scaleMetadata.initialScale.scaleX * 2f,
centroid = centroid,
)
}
}
}
}
@@ -1,35 +1,36 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFold
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.DEFAULT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.LEFT_TO_RIGHT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.WEBTOON
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
import me.saket.telephoto.zoomable.zoomable
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
@Composable
fun GalleryPager(
@@ -37,119 +38,82 @@ fun GalleryPager(
pagerState: PagerState,
lazyListState: LazyListState,
pageLoader: PageLoader2,
onSelectPage: (ReaderPage) -> Unit,
showNavigationOverlay: Boolean,
onNavigationModeChange: () -> Unit,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val items = pageLoader.pages
when (type) {
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> {
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = type == RIGHT_TO_LEFT,
key = { it },
) { index ->
val page = items[index]
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
val status by page.status.collectAsState()
val boxModifier = if (status == Page.State.READY) {
Modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { onSelectPage(page) },
)
} else {
Modifier.combinedClickable(
interactionSource = null,
indication = null,
onClick = { onMenuRegionClick() },
).fillMaxSize()
}
Box(
modifier = boxModifier,
contentAlignment = Alignment.Center,
) {
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = false,
)
}
}
}
VERTICAL -> {
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
val status by page.status.collectAsState()
val boxModifier = if (status == Page.State.READY) {
Modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { onSelectPage(page) },
)
} else {
Modifier.combinedClickable(
interactionSource = null,
indication = null,
onClick = { onMenuRegionClick() },
).fillMaxSize()
}
Box(
modifier = boxModifier,
contentAlignment = Alignment.Center,
) {
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = false,
)
}
}
}
WEBTOON, CONTINUOUS_VERTICAL -> {
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { ofs ->
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info == null && type == CONTINUOUS_VERTICAL) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
),
state = lazyListState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = true,
)
}
}
}
val isPagerType = !ReadingModeType.isWebtoon(type)
val pagerNavigation by Settings.readerPagerNav.collectAsState()
val pagerInvertMode by Settings.readerPagerNavInverted.collectAsState()
val webtoonNavigation by Settings.readerWebtoonNav.collectAsState()
val webtoonInvertMode by Settings.readerWebtoonNavInverted.collectAsState()
val navigationType = if (isPagerType) pagerNavigation else webtoonNavigation
val navigation = remember(navigationType, type) {
ViewerNavigation.fromPreference(navigationType, ReadingModeType.isVertical(type))
}
val invertMode = if (isPagerType) pagerInvertMode else webtoonInvertMode
var firstLaunch by remember { mutableStateOf(true) }
val regions = remember(navigation, invertMode) {
if (firstLaunch) {
firstLaunch = false
} else {
onNavigationModeChange()
}
navigation.regions(TappingInvertMode.entries[invertMode])
}
val navigator by rememberUpdatedState(regions)
if (isPagerType) {
val channel = remember { Channel<Float>(Channel.CONFLATED) }
LaunchedEffect(channel) {
channel.receiveAsFlow().collectLatest { delta ->
if (delta != 0f) {
if (delta < 0) pagerState.moveToNext() else pagerState.moveToPrevious()
}
}
}
PagerViewer(
pagerState = pagerState,
isRtl = type == RIGHT_TO_LEFT,
isVertical = type == VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
modifier = modifier.pointerInput(channel) {
awaitPointerEventScope {
while (true) {
val event = awaitScrollEvent()
val delta = calculateMouseWheelScroll(event)
channel.trySend(delta)
}
}
},
)
} else {
WebtoonViewer(
lazyListState = lazyListState,
withGaps = type == CONTINUOUS_VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
modifier = modifier,
)
}
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
}
private val zoomSpec = ZoomSpec(maxZoomFactor = 3f)
private suspend fun AwaitPointerEventScope.awaitScrollEvent(): PointerEvent {
var event: PointerEvent
do {
event = awaitPointerEvent()
} while (event.type != PointerEventType.Scroll)
return event
}
private fun Density.calculateMouseWheelScroll(event: PointerEvent): Float {
// 64 dp value is taken from ViewConfiguration.java, replace with better solution
return event.changes.fastFold(0f) { acc, c -> acc + c.scrollDelta.y } * -64.dp.toPx()
}
@@ -0,0 +1,70 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
@Composable
fun NavigationOverlay(
visible: Boolean,
regions: NavigationRegions,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible && regions.isNotEmpty(),
enter = fadeIn(AnimationSpec),
exit = fadeOut(AnimationSpec),
) {
BoxWithConstraints(modifier) {
val fontSize = MaterialTheme.typography.headlineSmall.fontSize
val style = TextStyle(
color = Color.White,
fontSize = fontSize,
)
val strokeStyle = TextStyle(
color = Color.Black,
fontSize = fontSize,
drawStyle = Stroke(8f),
)
val scaleX = maxWidth
val scaleY = maxHeight
regions.forEach { region ->
val rect = region.rect
val color = colorResource(region.type.colorRes)
val text = stringResource(region.type.nameRes)
Box(
modifier = Modifier.size(
width = scaleX * rect.width,
height = scaleY * rect.height,
).offset(
x = scaleX * rect.left,
y = scaleY * rect.top,
).background(color),
contentAlignment = Alignment.Center,
) {
Text(text, style = strokeStyle)
Text(text, style = style)
}
}
}
}
}
private val AnimationSpec = tween<Float>(1000)
@@ -16,6 +16,7 @@ import com.hippo.ehviewer.BuildConfig.APPLICATION_ID
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
@@ -25,7 +26,6 @@ import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.requestPermission
import com.hippo.files.toOkioPath
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlinx.datetime.Clock
@@ -42,7 +42,7 @@ private fun Context.provideImage(index: Int): Uri? {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
suspend fun shareImage(page: Page, info: GalleryInfo? = null) {
val error = getString(R.string.error_cant_save_image)
val share = getString(R.string.share_image)
val noActivity = getString(R.string.error_cant_find_activity)
@@ -67,7 +67,7 @@ suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun copy(page: ReaderPage) {
suspend fun copy(page: Page) {
val error = getString(R.string.error_cant_save_image)
val copied = getString(R.string.copied_to_clipboard)
val uri = provideImage(page.index)
@@ -83,7 +83,7 @@ suspend fun copy(page: ReaderPage) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun save(page: ReaderPage) {
suspend fun save(page: Page) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val cannotSave = getString(R.string.error_cant_save_image)
if (granted) {
@@ -137,7 +137,7 @@ suspend fun save(page: ReaderPage) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun saveTo(page: ReaderPage) {
suspend fun saveTo(page: Page) {
val filename = getImageFilename(page.index)
if (filename == null) {
showSnackbar(getString(R.string.error_cant_save_image))
@@ -11,6 +11,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -19,61 +20,77 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.google.accompanist.drawablepainter.DrawablePainter
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import eu.kanade.tachiyomi.source.model.Page
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.progressObserved
import com.hippo.ehviewer.gallery.statusObserved
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.ui.settings.AdsPlaceholderFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.CombinedCircularProgressIndicator
import me.saket.telephoto.zoomable.ZoomableContentLocation
import me.saket.telephoto.zoomable.ZoomableState
import kotlinx.coroutines.flow.drop
import moe.tarsin.kt.unreachable
@Composable
fun PagerItem(
page: ReaderPage,
page: Page,
pageLoader: PageLoader,
zoomableState: ZoomableState,
webtoon: Boolean,
contentScale: ContentScale,
modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier,
) {
val defaultError = stringResource(id = R.string.decode_image_error)
val state by page.status.collectAsState()
when (state) {
Page.State.QUEUE, Page.State.LOAD_PAGE, Page.State.DOWNLOAD_IMAGE -> {
LaunchedEffect(Unit) {
LaunchedEffect(Unit) {
pageLoader.request(page.index)
page.statusFlow.drop(1).collect {
if (page.statusFlow.value == PageStatus.Queued) {
pageLoader.request(page.index)
}
}
}
val defaultError = stringResource(id = R.string.decode_image_error)
when (val state = page.statusObserved) {
is PageStatus.Queued, is PageStatus.Loading -> {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
contentAlignment = Alignment.Center,
) {
val progress by page.progressFlow.collectAsState()
CombinedCircularProgressIndicator(progress = progress / 100f)
CombinedCircularProgressIndicator(progress = state.progressObserved)
}
}
Page.State.READY -> {
val image = page.image!!.innerImage!!
val rect = page.image!!.rect
val painter = remember(image) { image.toPainter(rect) }
val contentScale = if (webtoon) ContentScale.FillWidth else ContentScale.Inside
is PageStatus.Ready -> {
val image = state.image
val painter = remember(image) { image.toPainter() }
val grayScale by Settings.grayScale.collectAsState()
val invert by Settings.invertedColors.collectAsState()
DisposableEffect(image) {
image.isRecyclable = false
onDispose {
if (image.isRecyclable) {
pageLoader.notifyPageWait(page.index)
image.recycle()
} else {
image.isRecyclable = true
}
}
}
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
modifier = contentModifier.fillMaxSize(),
contentScale = contentScale,
colorFilter = when {
grayScale && invert -> grayScaleAndInvertFilter
@@ -82,22 +99,37 @@ fun PagerItem(
else -> null
},
)
if (!webtoon) {
val size = painter.intrinsicSize
LaunchedEffect(size) {
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
zoomableState.setContentLocation(contentLocation)
}
is PageStatus.Blocked -> {
SubcomposeAsyncImage(
model = AdsPlaceholderFile,
contentDescription = null,
modifier = modifier.fillMaxSize(),
contentScale = if (contentScale == ContentScale.Inside) ContentScale.Fit else contentScale,
) {
val placeholderState by painter.state.collectAsState()
if (placeholderState is AsyncImagePainter.State.Success) {
SubcomposeAsyncImageContent()
} else {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
contentAlignment = Alignment.Center,
) {
if (placeholderState is AsyncImagePainter.State.Error) {
Text(text = stringResource(id = R.string.blocked_image))
}
}
}
}
}
Page.State.ERROR -> {
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
is PageStatus.Error -> {
Box(modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = page.errorMsg ?: defaultError,
text = state.message ?: defaultError,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
@@ -111,13 +143,9 @@ fun PagerItem(
}
}
private fun Image.toPainter(rect: IntRect) = when (this) {
is BitmapImage -> BitmapPainter(
image = bitmap.asImageBitmap(),
srcOffset = rect.topLeft,
srcSize = rect.size,
)
is DrawableImage -> DrawablePainter(drawable)
private fun Image.toPainter() = when (val image = innerImage) {
is BitmapImage -> BitmapPainter(image.bitmap, intrinsicSize.toSize())
is DrawableImage -> DrawablePainter(image.drawable)
else -> unreachable()
}
@@ -0,0 +1,265 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.FixedScale
import androidx.compose.ui.layout.times
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.toSize
import arrow.core.partially1
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.statusObserved
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
import kotlin.contracts.contract
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableContentLocation
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
import me.saket.telephoto.zoomable.zoomable
@Composable
fun PagerViewer(
pagerState: PagerState,
isRtl: Boolean,
isVertical: Boolean,
pageLoader: PageLoader2,
navigator: () -> NavigationRegions,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val items = pageLoader.pages
val scaleType by Settings.imageScaleType.collectAsState()
val landscapeZoom by Settings.landscapeZoom.collectAsState()
val zoomStart by Settings.zoomStart.collectAsState()
val alignment = Alignment.fromPreferences(zoomStart, isRtl, isVertical)
val layoutSize by remember(pagerState) {
derivedStateOf {
pagerState.layoutInfo.viewportSize.toSize()
}
}
if (isVertical) {
VerticalPager(
state = pagerState,
modifier = modifier,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = false,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigator = navigator,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
} else {
val isRtlLayout = LocalLayoutDirection.current == LayoutDirection.Rtl
HorizontalPager(
state = pagerState,
modifier = modifier,
reverseLayout = isRtl xor isRtlLayout,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = isRtl,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigator = navigator,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
}
}
@Composable
private fun PageContainer(
page: Page,
pageLoader: PageLoader2,
isRtl: Boolean,
scaleType: Int,
landscapeZoom: Boolean,
alignment: Alignment.Horizontal,
layoutSize: Size,
navigator: () -> NavigationRegions,
pagerState: PagerState,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
scope: CoroutineScope,
) {
@Suppress("NAME_SHADOWING")
val isRtl by rememberUpdatedState(isRtl)
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
val status = page.statusObserved
if (status is PageStatus.Ready && layoutSize != Size.Zero) {
val size = status.image.intrinsicSize.toSize()
val contentScale = ContentScale.fromPreferences(scaleType, size, layoutSize)
zoomableState.contentScale = contentScale
LaunchedEffect(size, contentScale, alignment) {
val contentSize = if (contentScale is FixedScale) { // Original
size
} else {
size * contentScale.computeScaleFactor(size, layoutSize)
}
val horizontalAlignment = if (contentSize.width > layoutSize.width) {
alignment
} else {
Alignment.CenterHorizontally
}
val verticalAlignment = if (contentSize.height > layoutSize.height) {
Alignment.Top
} else {
Alignment.CenterVertically
}
zoomableState.contentAlignment = horizontalAlignment + verticalAlignment
}
LaunchedEffect(size) {
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
zoomableState.setContentLocation(contentLocation)
}
if (landscapeZoom && contentScale == ContentScale.Fit && size.width > size.height) {
LaunchedEffect(alignment) {
val zoomFraction = snapshotFlow { zoomableState.zoomFraction }.first { it != null }
if (zoomFraction == 0f) {
delay(500)
val contentSize = zoomableState.transformedContentBounds.size
val scale = ContentScale.FillHeight.computeScaleFactor(contentSize, layoutSize)
val targetScale = scale.scaleX.coerceAtMost(zoomableState.zoomSpec.maxZoomFactor)
val offset = alignment.align(0, layoutSize.width.toInt(), LayoutDirection.Ltr)
zoomableState.zoomTo(targetScale, Offset(offset.toFloat(), 0f))
}
}
}
}
val onLongClick = { _: Offset -> onSelectPage(page) }
val onTap: ZoomableState?.(Offset) -> Unit = { offset ->
scope.launch {
with(pagerState) {
// Don't use `layoutSize` as it may capture outdated value
val size = layoutInfo.viewportSize.toSize()
val (w, h) = size
val (x, y) = offset
val distance = size.width
val bounds = size.toRect()
when (navigator().getAction(Offset(x / w, y / h))) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT -> {
val canPan = if (isRtl) panRight(distance, bounds) else panLeft(distance, bounds)
if (!canPan) {
moveToNext()
}
}
NavigationRegion.PREV -> {
val canPan = if (isRtl) panLeft(distance, bounds) else panRight(distance, bounds)
if (!canPan) {
moveToPrevious()
}
}
NavigationRegion.RIGHT -> {
if (!panLeft(distance, bounds)) {
if (isRtl) moveToPrevious() else moveToNext()
}
}
NavigationRegion.LEFT -> {
if (!panRight(distance, bounds)) {
if (isRtl) moveToNext() else moveToPrevious()
}
}
}
}
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PagerItem(
page = page,
pageLoader = pageLoader,
contentScale = ContentScale.Inside,
modifier = Modifier.pointerInput(onTap) {
detectTapGestures(onLongPress = onLongClick, onTap = onTap.partially1(null))
},
contentModifier = Modifier.zoomable(
state = zoomableState,
onClick = onTap.partially1(zoomableState),
onLongClick = onLongClick,
onDoubleClick = DoubleTapZoom,
),
)
}
}
private suspend fun ZoomableState?.panLeft(distance: Float, bounds: Rect): Boolean =
if (canPan { it.right - bounds.right }) {
panBy(Offset(-distance, 0f))
true
} else {
false
}
private suspend fun ZoomableState?.panRight(distance: Float, bounds: Rect): Boolean =
if (canPan { bounds.left - it.left }) {
panBy(Offset(distance, 0f))
true
} else {
false
}
private inline fun ZoomableState?.canPan(getRemaining: (Rect) -> Float): Boolean {
// TODO: Remove when K2 mode in IDE is stable
contract {
returns(true) implies (this@canPan != null)
}
return this != null && Settings.navigateToPan.value && getRemaining(transformedContentBounds) > 1f
}
private val PagerZoomSpec = ZoomSpec(maxZoomFactor = 5f)
@@ -1,13 +1,17 @@
package com.hippo.ehviewer.ui.reader
import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -18,9 +22,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -28,28 +34,44 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import androidx.core.view.WindowInsetsControllerCompat
import arrow.core.Either
import arrow.core.raise.ensure
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.hasAds
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.archiveFile
import com.hippo.ehviewer.gallery.ArchivePageLoader
import com.hippo.ehviewer.gallery.EhPageLoader
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.status
import com.hippo.ehviewer.gallery.unblock
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.util.displayString
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -59,27 +81,99 @@ import eu.kanade.tachiyomi.ui.reader.ReaderContentOverlay
import eu.kanade.tachiyomi.ui.reader.ReaderPageSheetMeta
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlin.coroutines.resume
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import moe.tarsin.kt.unreachable
sealed interface ReaderScreenArgs : Parcelable {
@Parcelize
data class Gallery(val info: BaseGalleryInfo, val page: Int) : ReaderScreenArgs
@Parcelize
data class Archive(val uri: Uri) : ReaderScreenArgs
}
@Composable
private fun Background(
color: Color,
content: @Composable () -> Unit,
) = Box(Modifier.fillMaxSize().background(color), contentAlignment = Alignment.Center) {
EhTheme(useDarkTheme = color != Color.White, content = content)
}
@Destination<RootGraph>
@Composable
fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
ConfigureKeepScreenOn()
val pageLoader = remember {
val archive = DownloadManager.getDownloadInfo(info.gid)?.archiveFile
archive?.let { ArchivePageLoader(it, info.gid, page) } ?: EhPageLoader(info, page)
}
LaunchedEffect(Unit) {
Settings.cropBorder.changesFlow().collect {
pageLoader.restart()
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
Await({ preparePageLoader(args) }) { pageLoader ->
DisposableEffect(pageLoader) {
pageLoader.start()
onDispose {
pageLoader.stop()
}
}
val bgColor by collectBackgroundColorAsState()
val readingFailed = stringResource(R.string.error_reading_failed)
val uiController = rememberSystemUiController()
DisposableEffect(uiController) {
val lightStatusBar = uiController.statusBarDarkContentEnabled
onDispose {
uiController.statusBarDarkContentEnabled = lightStatusBar
}
}
LaunchedEffect(uiController) {
snapshotFlow { bgColor }.collect {
uiController.statusBarDarkContentEnabled = it == Color.White
}
}
Await(
{
Either.catch {
readingFailed.takeUnless { pageLoader.awaitReady() }
}.fold({ it.displayString() }, { it })
},
placeholder = {
Background(bgColor) {
CircularProgressIndicator()
}
},
) { error ->
if (error == null) {
val info = (args as? ReaderScreenArgs.Gallery)?.info
key(pageLoader) {
ReaderScreen(pageLoader, info, navigator)
}
} else {
Background(bgColor) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
}
}
}
DisposableEffect(pageLoader) {
pageLoader.start()
onDispose {
pageLoader.stop()
}
@Composable
fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGalleryInfo?, navigator: DestinationsNavigator) = composing(navigator) {
ConfigureKeepScreenOn()
LaunchedEffect(Unit) {
val orientation = requestedOrientation
Settings.orientationMode.valueFlow()
.onCompletion { requestedOrientation = orientation }
.collect { setOrientation(it) }
}
LaunchedEffect(pageLoader) {
with(Settings) {
merge(cropBorder.changesFlow(), stripExtraneousAds.changesFlow()).collect {
pageLoader.restart()
}
}
}
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
@@ -95,132 +189,186 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
}
}
Deferred({ pageLoader.awaitReady() }) {
val lazyListState = rememberLazyListState()
val pagerState = rememberPagerState { pageLoader.size }
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
syncState.Sync(ReadingModeType.isWebtoon(readingMode))
Box {
var appbarVisible by remember { mutableStateOf(false) }
val bgColor by collectBackgroundColorAsState()
if (fullscreen) {
LaunchedEffect(Unit) {
snapshotFlow { appbarVisible }.collect {
uiController.isSystemBarsVisible = it
val lazyListState = rememberLazyListState(pageLoader.startPage)
val pagerState = rememberPagerState(pageLoader.startPage) { pageLoader.size }
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
Box {
var appbarVisible by remember { mutableStateOf(false) }
val bgColor by collectBackgroundColorAsState()
val isWebtoon by rememberUpdatedState(ReadingModeType.isWebtoon(readingMode))
syncState.Sync(isWebtoon) { appbarVisible = false }
if (fullscreen) {
LaunchedEffect(Unit) {
snapshotFlow { appbarVisible }.collect {
uiController.isSystemBarsVisible = it
}
}
}
VolumeKeysHandler(
enabled = { volumeKeysEnabled && !appbarVisible },
movePrevious = {
if (isWebtoon) lazyListState.scrollUp() else pagerState.moveToPrevious()
},
moveNext = {
if (isWebtoon) lazyListState.scrollDown() else pagerState.moveToNext()
},
)
var showNavigationOverlay by remember {
val showOnStart = Settings.showNavigationOverlayNewUser.value || Settings.showNavigationOverlayOnStart.value
Settings.showNavigationOverlayNewUser.value = false
mutableStateOf(showOnStart)
}
val onSelectPage = { page: Page ->
if (Settings.readerLongTapAction.value) {
launch {
val blocked = page.status is PageStatus.Blocked
dialog { cont ->
fun dispose() = cont.resume(Unit)
val state = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
sheetState = state,
contentWindowInsets = { EmptyWindowInsets },
) {
ReaderPageSheetMeta(
retry = { pageLoader.retryPage(page.index) },
retryOrigin = { pageLoader.retryPage(page.index, true) },
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
copy = { launchIO { with(pageLoader) { copy(page) } } },
save = { launchIO { with(pageLoader) { save(page) } } },
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
showAds = { page.unblock() }.takeIf { blocked },
dismiss = { launch { state.hide().also { dispose() } } },
)
}
}
}
}
VolumeKeysHandler(
enabled = { volumeKeysEnabled && !appbarVisible },
movePrevious = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
moveNext = { syncState.sliderScrollTo(syncState.sliderValue + 1) },
)
}
EhTheme(useDarkTheme = bgColor != Color.White) {
val insets = if (fullscreen) {
if (cutoutShort) {
EmptyWindowInsets
} else {
WindowInsets.displayCutout
}
} else {
WindowInsets.systemBars
}
GalleryPager(
type = readingMode,
pagerState = pagerState,
lazyListState = lazyListState,
pageLoader = pageLoader,
onSelectPage = { page ->
if (Settings.readerLongTapAction.value) {
launch {
dialog { cont ->
fun dispose() = cont.resume(Unit)
val state = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
sheetState = state,
contentWindowInsets = { WindowInsets(0) },
) {
ReaderPageSheetMeta(
retry = { pageLoader.retryPage(page.index) },
retryOrigin = { pageLoader.retryPage(page.index, true) },
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
copy = { launchIO { with(pageLoader) { copy(page) } } },
save = { launchIO { with(pageLoader) { save(page) } } },
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
dismiss = { launch { state.hide().also { dispose() } } },
)
}
}
}
}
},
showNavigationOverlay = showNavigationOverlay,
onNavigationModeChange = { showNavigationOverlay = true },
onSelectPage = onSelectPage,
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor),
contentPadding = if (fullscreen) {
if (cutoutShort) {
PaddingValues(0.dp)
} else {
WindowInsets.displayCutout.asPaddingValues()
modifier = Modifier.background(bgColor).pointerInput(syncState) {
awaitEachGesture {
waitForUpOrCancellation()
syncState.reset()
showNavigationOverlay = false
}
} else {
WindowInsets.systemBars.asPaddingValues()
},
}.fillMaxSize().windowInsetsPadding(insets),
)
val brightness by Settings.customBrightness.collectAsState()
val brightnessValue by Settings.customBrightnessValue.collectAsState()
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
val colorOverlay by Settings.colorFilterValue.collectAsState()
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
when (it) {
0 -> BlendMode.SrcOver
1 -> BlendMode.Multiply
2 -> BlendMode.Screen
3 -> BlendMode.Overlay
4 -> BlendMode.Lighten
5 -> BlendMode.Darken
else -> unreachable()
}
}
val brightness by Settings.customBrightness.collectAsState()
val brightnessValue by Settings.customBrightnessValue.collectAsState()
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
val colorOverlay by Settings.colorFilterValue.collectAsState()
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
when (it) {
0 -> BlendMode.SrcOver
1 -> BlendMode.Multiply
2 -> BlendMode.Screen
3 -> BlendMode.Overlay
4 -> BlendMode.Lighten
5 -> BlendMode.Darken
else -> unreachable()
}
ReaderContentOverlay(
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
color = { colorOverlay }.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayMode,
)
val showPageNumber by Settings.showPageNumber.collectAsState()
if (showPageNumber && !appbarVisible) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
PageIndicatorText(
currentPage = syncState.sliderValue,
totalPages = pageLoader.size,
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
)
}
}
ReaderContentOverlay(
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
color = { colorOverlay }.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayMode,
)
if (brightness) {
LaunchedEffect(Unit) {
Settings.customBrightnessValue.valueFlow().sample(100)
.onCompletion { setCustomBrightnessValue(0) }
.collect { setCustomBrightnessValue(it) }
}
ReaderAppBars(
visible = appbarVisible,
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
showSeekBar = showSeekbar,
currentPage = syncState.sliderValue,
totalPages = pageLoader.size,
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
onClickSettings = {
launch {
dialog { cont ->
fun dispose() = cont.resume(Unit)
var isColorFilter by remember { mutableStateOf(false) }
val scrim by animateColorAsState(
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
label = "ScrimColor",
)
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
dragHandle = null,
contentWindowInsets = { WindowInsets(0) },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
appbarVisible = !isColorFilter
}
}
val showPageNumber by Settings.showPageNumber.collectAsState()
if (showPageNumber && !appbarVisible) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
PageIndicatorText(
currentPage = syncState.sliderValue,
totalPages = pageLoader.size,
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
)
}
}
ReaderAppBars(
visible = appbarVisible,
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
showSeekBar = showSeekbar,
currentPage = syncState.sliderValue,
totalPages = pageLoader.size,
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
onClickSettings = {
launch {
dialog { cont ->
fun dispose() = cont.resume(Unit)
var isColorFilter by remember { mutableStateOf(false) }
val scrim by animateColorAsState(
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
label = "ScrimColor",
)
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
dragHandle = null,
contentWindowInsets = { EmptyWindowInsets },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
appbarVisible = !isColorFilter
}
}
}
},
)
}
},
)
}
}
context(Context, DialogState, DestinationsNavigator)
private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
is ReaderScreenArgs.Gallery -> {
val info = args.info
val page = args.page
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
if (archive != null) {
ArchivePageLoader(archive, info.gid, page, info.hasAds)
} else {
EhPageLoader(info, page)
}
}
is ReaderScreenArgs.Archive -> {
ArchivePageLoader(args.uri.toOkioPath()) { invalidator ->
awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
onUserDismiss = { popBackStack() },
) { text ->
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
}
}
}
}
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
@@ -8,12 +7,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.gallery.PageLoader2
import kotlin.math.absoluteValue
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
@@ -23,8 +21,7 @@ class SliderPagerDoubleSync(
private val pagerState: PagerState,
private val pageLoader: PageLoader2,
) {
private var requestScroll = true
private var sliderFollowPager = true
private var sliderFollowPager by mutableStateOf(true)
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
private set
@@ -33,14 +30,16 @@ class SliderPagerDoubleSync(
sliderValue = index.coerceIn(1, pageLoader.size)
}
fun reset() {
sliderFollowPager = true
}
@Composable
fun Sync(webtoon: Boolean) {
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
if (fling || pagerFling) sliderFollowPager = true
fun Sync(webtoon: Boolean, onPageSelected: () -> Unit) {
val currentIndexFlow = remember(webtoon) {
requestScroll = true
val initialIndex = sliderValue - 1
if (webtoon) {
sliderFollowPager = lazyListState.firstVisibleItemIndex == initialIndex
snapshotFlow {
with(lazyListState.layoutInfo) {
visibleItemsInfo.lastOrNull {
@@ -49,44 +48,28 @@ class SliderPagerDoubleSync(
}
}.filterNotNull()
} else {
sliderFollowPager = pagerState.currentPage == initialIndex
snapshotFlow { pagerState.currentPage }
}
}
LaunchedEffect(currentIndexFlow) {
currentIndexFlow.collect { index ->
if (sliderFollowPager && !requestScroll) {
if (sliderFollowPager) {
LaunchedEffect(currentIndexFlow) {
currentIndexFlow.collect { index ->
sliderValue = index + 1
pageLoader.startPage = index
onPageSelected()
}
}
}
LaunchedEffect(webtoon) {
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
if (sliderFollowPager && !requestScroll) return@collectLatest
if (webtoon) {
if (requestScroll) {
lazyListState.requestScrollToItem(index)
requestScroll = false
} else {
LaunchedEffect(webtoon) {
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
if (webtoon) {
lazyListState.scrollToItem(index)
} else {
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
lazyListState.animateScrollToItem(index)
} else {
lazyListState.scrollToItem(index)
}
}
} else {
if (requestScroll) {
pagerState.requestScrollToPage(index)
requestScroll = false
} else {
if (smoothScroll((index - pagerState.currentPage))) {
pagerState.animateScrollToPage(index)
} else {
pagerState.scrollToPage(index)
}
pagerState.animateScrollToPage(index)
}
pageLoader.startPage = index
}
pageLoader.startPage = index
}
}
}
@@ -101,8 +84,3 @@ fun rememberSliderPagerDoubleSyncState(
): SliderPagerDoubleSync = remember {
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
}
private fun smoothScroll(distance: Int) =
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
private const val SMOOTH_SCROLL_THRESHOLD = 50
@@ -3,13 +3,23 @@ package com.hippo.ehviewer.ui.reader
import android.app.Activity
import android.view.Window
import android.view.WindowManager
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.FixedScale
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import kotlinx.coroutines.flow.onCompletion
private fun Window.updateKeepScreenOn(enabled: Boolean) {
fun Window.updateKeepScreenOn(enabled: Boolean) {
if (enabled) {
addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
@@ -19,11 +29,98 @@ private fun Window.updateKeepScreenOn(enabled: Boolean) {
@Composable
fun Activity.ConfigureKeepScreenOn() {
val on by Settings.keepScreenOn.collectAsState()
DisposableEffect(on) {
window.updateKeepScreenOn(on)
onDispose {
window.updateKeepScreenOn(false)
}
LaunchedEffect(Unit) {
Settings.keepScreenOn.valueFlow()
.onCompletion { window.updateKeepScreenOn(false) }
.collect { window.updateKeepScreenOn(it) }
}
}
/**
* Forces the user preferred [orientation] on the activity.
*/
fun Activity.setOrientation(orientation: Int) {
val newOrientation = OrientationType.fromPreference(orientation)
if (newOrientation.flag != requestedOrientation) {
requestedOrientation = newOrientation.flag
}
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
* From 1 to 100 it sets that value as brightness.
* 0 sets system brightness and hides the overlay.
*/
fun Activity.setCustomBrightnessValue(value: Int) {
val readerBrightness = when {
value > 0 -> value / 100f
value < 0 -> 0.01f
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
@Stable
fun Alignment.Companion.fromPreferences(value: Int, isRtl: Boolean, isVertical: Boolean) = when (value) {
1 -> when {
isVertical -> CenterHorizontally
isRtl -> AbsoluteAlignment.Right
else -> AbsoluteAlignment.Left
}
2 -> AbsoluteAlignment.Left
3 -> AbsoluteAlignment.Right
else -> CenterHorizontally
}
@Stable
fun ContentScale.Companion.fromPreferences(value: Int, srcSize: Size, dstSize: Size) = when (value) {
2 -> Crop
3 -> FillWidth
4 -> FillHeight
5 -> FixedScale(1 / Inside.computeScaleFactor(srcSize, dstSize).scaleX)
6 -> if (srcSize.width > srcSize.height) FillHeight else FillWidth
else -> Fit
}
suspend fun PagerState.performScrollToPage(page: Int) {
if (Settings.pageTransitions.value) {
animateScrollToPage(page)
} else {
scrollToPage(page)
}
}
suspend fun PagerState.moveToPrevious() {
val target = currentPage - 1
if (target >= 0) {
performScrollToPage(target)
}
}
suspend fun PagerState.moveToNext() {
val target = currentPage + 1
if (target < pageCount) {
performScrollToPage(target)
}
}
suspend fun LazyListState.performScrollBy(value: Float) {
if (Settings.pageTransitions.value) {
animateScrollBy(value)
} else {
scrollBy(value)
}
}
suspend fun LazyListState.scrollUp() {
performScrollBy(-scrollDistance)
}
suspend fun LazyListState.scrollDown() {
performScrollBy(scrollDistance)
}
private val LazyListState.scrollDistance
get() = layoutInfo.viewportSize.height * 0.75f
@@ -8,12 +8,15 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import com.hippo.ehviewer.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
context(CoroutineScope)
@Composable
fun VolumeKeysHandler(
enabled: () -> Boolean,
movePrevious: () -> Unit,
moveNext: () -> Unit,
movePrevious: suspend () -> Unit,
moveNext: suspend () -> Unit,
) {
val view = LocalView.current
DisposableEffect(view) {
@@ -24,10 +27,12 @@ fun VolumeKeysHandler(
val interval = Settings.readWithVolumeKeysInterval.value + 1
val inverted = Settings.readWithVolumeKeysInverted.value
if (event.action == ACTION_DOWN && event.repeatCount % interval == 0) {
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
movePrevious()
} else {
moveNext()
launch {
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
movePrevious()
} else {
moveNext()
}
}
}
true
@@ -0,0 +1,93 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
import kotlinx.coroutines.launch
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
import me.saket.telephoto.zoomable.zoomable
@Composable
fun WebtoonViewer(
lazyListState: LazyListState,
withGaps: Boolean,
pageLoader: PageLoader2,
navigator: () -> NavigationRegions,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val items = pageLoader.pages
val zoomableState = rememberZoomableState(zoomSpec = WebtoonZoomSpec)
val density = LocalDensity.current
val paddingPercent by Settings.webtoonSidePadding.collectAsState()
val sidePadding by remember(density) {
snapshotFlow {
with(density) {
(lazyListState.layoutInfo.viewportSize.width * paddingPercent / 100f).toDp()
}
}
}.collectAsState(0.dp)
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = { offset ->
scope.launch {
with(lazyListState) {
val (w, h) = layoutInfo.viewportSize
val (x, y) = offset
when (navigator().getAction(Offset(x / w, y / h))) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
}
}
}
},
onLongClick = { ofs ->
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info != null) {
onSelectPage(items[info.index])
}
},
onDoubleClick = DoubleTapZoom,
),
state = lazyListState,
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(if (withGaps) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
PagerItem(
page = page,
pageLoader = pageLoader,
contentScale = ContentScale.FillWidth,
)
}
}
}
private val WebtoonZoomSpec = ZoomSpec(maxZoomFactor = 3f)
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.view.ViewConfiguration
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
@@ -19,7 +20,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -105,7 +105,8 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
@@ -131,7 +132,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Destination<RootGraph>
@Composable
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
var gridView by Settings.gridView.asMutableState()
var sortMode by Settings.downloadSortMode.asMutableState()
val filterMode by Settings.downloadFilterMode.collectAsState { DownloadsFilterMode.from(it) }
@@ -150,7 +151,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
val density = LocalDensity.current
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
fun getTranslation(tag: String) = ehTags?.run {
getTranslation(TagNamespace.Artist.toPrefix(), tag)
?: getTranslation(TagNamespace.Cosplayer.toPrefix(), tag)
} ?: tag
val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name)
val unknownName = stringResource(R.string.unknown_artists)
@@ -164,7 +168,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
when (label) {
"" -> allName
null -> emptyLabelName
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
else -> if (mode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
}
},
)
@@ -214,7 +218,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun closeSheet() = launch { drawerState.close() }
TopAppBar(
title = { Text(text = labelsStr) },
windowInsets = WindowInsets(0, 0, 0, 0),
windowInsets = EmptyWindowInsets,
actions = {
if (DownloadsFilterMode.CUSTOM == filterMode) {
IconButton(
@@ -295,7 +299,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
@@ -336,7 +340,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
snapshotFlow { dismissState.currentValue }.collect {
if (it == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_label, item))
}
}.onSuccess {
@@ -351,7 +355,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
}
ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { isDragging ->
ReorderableItem(
reorderableLabelState,
id,
enabled = editEnable,
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
) { isDragging ->
SwipeToDismissBox(
state = dismissState,
backgroundContent = {},
@@ -374,7 +383,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
tonalElevation = 1.dp,
shadowElevation = elevation,
headlineContent = {
val name = if (filterMode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
val name = if (filterMode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
Text("$name [${downloadsCount.getOrDefault(item, 0)}]")
},
trailingContent = editEnable.ifTrueThen {
@@ -480,7 +489,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
onClick = {
expanded = false
launchIO {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = android.R.string.ok,
dismissText = android.R.string.cancel,
) {
@@ -599,7 +608,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
Deferred({ delay(200) }) {
Await({ delay(200) }) {
if (list.isEmpty()) {
Column(
modifier = Modifier.padding(realPadding).fillMaxSize(),
@@ -642,7 +651,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
) {
if (!selectMode) {
onClick(Icons.Default.Shuffle) {
withUIContext { navToReader(list.random().galleryInfo) }
if (list.isNotEmpty()) {
withUIContext { navToReader(list.random().galleryInfo) }
}
}
onClick(Icons.AutoMirrored.Default.Sort) {
val oldMode = SortMode.from(sortMode)
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.view.ViewConfiguration
import androidx.collection.MutableLongSet
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
@@ -73,6 +74,7 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.rememberInVM
@@ -94,7 +96,7 @@ import moe.tarsin.coroutines.runSwallowingWithUI
@Destination<RootGraph>
@Composable
fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
// Immutables
val localFavName = stringResource(R.string.local_favorites)
val cloudFavName = stringResource(R.string.cloud_favorites)
@@ -184,7 +186,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
val localFavCount by localFavCountFlow.collectAsState(0)
TopAppBar(
title = { Text(text = stringResource(id = R.string.collections)) },
windowInsets = WindowInsets(0, 0, 0, 0),
windowInsets = EmptyWindowInsets,
)
val scope = currentRecomposeScope
LaunchedEffect(Unit) {
@@ -355,8 +357,9 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
if (!selectMode) {
if (isLocalFav) {
onClick(Icons.Default.Shuffle) {
val random = EhDB.randomLocalFav()
withUIContext { navigate(random.asDst()) }
EhDB.randomLocalFav()?.let { info ->
withUIContext { navigate(info.asDst()) }
}
}
}
onClick(EhIcons.Default.GoTo) {
@@ -382,7 +385,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
onClick(Icons.Default.Delete) {
val info = checkedInfoMap.takeAndClear()
awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
awaitConfirmationOrCancel(title = R.string.delete_favorites_dialog_title) {
Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
}
val srcCat = urlBuilder.favCat
@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.widget.TextView
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -59,6 +62,7 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
@@ -86,7 +90,6 @@ import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.normalizeSpan
@@ -147,7 +150,7 @@ private val MinimumContentPaddingEditText = 88.dp
@Destination<RootGraph>
@Composable
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var commenting by rememberSaveable { mutableStateOf(false) }
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
@@ -208,7 +211,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
val filterAdded = stringResource(R.string.filter_added)
suspend fun showFilterCommenter(comment: GalleryComment) {
val commenter = comment.user ?: return
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
Filter(FilterMode.COMMENTER, commenter).remember()
comments = comments.copy(comments = comments.comments.filterNot { it.user == commenter })
showSnackbar(filterAdded)
@@ -286,7 +289,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
isRefreshing = false
}
},
modifier = Modifier.fillMaxSize().imePadding(),
modifier = Modifier.fillMaxSize().imePadding().padding(top = paddingValues.calculateTopPadding()),
) {
val additionalPadding = if (commenting) {
editTextMeasured
@@ -302,10 +305,15 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
val voteDownSucceed = stringResource(R.string.vote_down_successfully)
val cancelVoteDownSucceed = stringResource(R.string.cancel_vote_down_successfully)
val voteFailed = stringResource(R.string.vote_failed)
val layoutDirection = LocalLayoutDirection.current
LazyColumn(
modifier = Modifier.padding(horizontal = keylineMargin),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = paddingValues + PaddingValues(bottom = additionalPadding),
contentPadding = PaddingValues(
start = paddingValues.calculateStartPadding(layoutDirection),
end = paddingValues.calculateEndPadding(layoutDirection),
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
),
) {
items(
items = comments.comments,
@@ -0,0 +1,801 @@
package com.hippo.ehviewer.ui.screen
import android.Manifest
import android.content.Context
import android.os.Environment
import android.text.TextUtils.TruncateAt.END
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapVerticalCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import arrow.fx.coroutines.parMap
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.asGalleryDetail
import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.confirmRemoveDownload
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
import com.hippo.ehviewer.ui.main.TorrentList
import com.hippo.ehviewer.ui.modifyFavorites
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
import com.hippo.ehviewer.ui.tools.getLimit
import com.hippo.ehviewer.ui.tools.getOffset
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.flattenForEach
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
context(CoroutineScope, DestinationsNavigator, DialogState, MainActivity, SnackbarHostState, SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryDetailContent(
galleryInfo: GalleryInfo,
contentPadding: PaddingValues,
getDetailError: String,
onRetry: () -> Unit,
modifier: Modifier,
) {
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
val galleryDetail = galleryInfo.asGalleryDetail()
val windowSizeClass = LocalWindowSizeClass.current
val thumbColumns by Settings.thumbColumns.collectAsState()
val readText = stringResource(R.string.read)
val startPage by rememberInVM {
EhDB.getReadProgressFlow(galleryInfo.gid)
}.collectAsState(0)
val readButtonText = if (startPage == 0) {
readText
} else {
stringResource(R.string.read_from, startPage + 1)
}
val downloadState by DownloadManager.collectDownloadState(galleryInfo.gid)
val downloadButtonText = when (downloadState) {
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
else -> error("Invalid DownloadState!!!")
}
fun onReadButtonClick() {
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
navToReader(galleryInfo.findBaseInfo(), startPage)
}
}
fun onCategoryChipClick() {
val category = galleryInfo.category
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
return
}
navigate(ListUrlBuilder(category = category).asDst())
}
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_UPLOADER, mKeyword = uploader).asDst())
}
fun onGalleryInfoCardClick() {
galleryDetail ?: return
launch {
dialog { cont ->
ModalBottomSheet(
onDismissRequest = { cont.cancel() },
contentWindowInsets = { EmptyWindowInsets },
) {
GalleryInfoBottomSheet(galleryDetail)
}
}
}
}
val filterAdded = stringResource(R.string.filter_added)
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
launchIO {
awaitConfirmationOrCancel {
Text(text = stringResource(R.string.filter_the_uploader, uploader))
}
Filter(FilterMode.UPLOADER, uploader).remember()
showSnackbar(filterAdded)
}
}
fun onDownloadButtonClick() {
galleryDetail ?: return
if (DownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
launchUI { startDownload(implicit<MainActivity>(), false, galleryDetail.galleryInfo) }
} else {
launch { confirmRemoveDownload(galleryDetail) }
}
}
val previews = galleryDetail?.let { collectPreviewItems(it, thumbColumns) }
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(
key = "header",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "header",
) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
)
}
item(
key = "body",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "body",
) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
Row {
FilledTonalButton(
onClick = ::onDownloadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = downloadButtonText, maxLines = 1)
}
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = readButtonText, maxLines = 1)
}
}
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (previews != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
WindowWidthSizeClass.EXPANDED -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(
key = "header",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "header",
) {
Row(modifier = Modifier.fillMaxWidth()) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = modifier.height(16.dp))
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = readButtonText, maxLines = 1)
}
Spacer(modifier = modifier.height(24.dp))
FilledTonalButton(
onClick = ::onDownloadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = downloadButtonText, maxLines = 1)
}
}
}
}
item(
key = "body",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "body",
) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (previews != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
}
}
context(Context, CoroutineScope, DestinationsNavigator, DialogState, SnackbarHostState)
@Composable
fun BelowHeader(galleryDetail: GalleryDetail) {
@Composable
fun EhIconButton(
icon: ImageVector,
text: String,
onClick: () -> Unit,
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledTertiaryIconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
Text(text = text)
}
@Composable
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
val maxShowCount = 2
val commentText = when {
commentsList.isEmpty() -> stringResource(R.string.no_comments)
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
GalleryCommentCard(
modifier = Modifier.padding(vertical = 4.dp),
comment = item,
onCardClick = ::onNavigateToCommentScene,
onUserClick = ::onNavigateToCommentScene,
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
maxLines = 5
ellipsize = END
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = ::onNavigateToCommentScene),
contentAlignment = Alignment.Center,
) {
Text(commentText)
}
}
}
suspend fun showNewerVersionDialog() {
val items = galleryDetail.newerVersions.map {
getString(R.string.newer_version_title, it.title, it.posted)
}
val selected = awaitSelectItem(items)
val info = galleryDetail.newerVersions[selected]
withUIContext {
// Can't use GalleryInfoArgs as thumbKey is null
navigate(info.gid asDstWith info.token)
}
}
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
Spacer(modifier = Modifier.size(keylineMargin))
if (galleryDetail.newerVersions.isNotEmpty()) {
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = { launchIO { showNewerVersionDialog() } },
modifier = Modifier.fillMaxWidth().height(32.dp),
) {
}
Text(text = stringResource(id = R.string.newer_version_available))
}
Spacer(modifier = Modifier.size(keylineMargin))
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
) {
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
val favButtonText = if (favSlot != NOT_FAVORITED) {
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
} else {
stringResource(id = R.string.not_favorited)
}
val favoritesLock = remember { MutatorMutex() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
val addSucceed = stringResource(R.string.add_to_favorite_success)
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
val addFailed = stringResource(R.string.add_to_favorite_failure)
FilledTertiaryIconToggleButton(
checked = favSlot != NOT_FAVORITED,
onCheckedChange = {
launchIO {
favoritesLock.mutate {
runSuspendCatching {
modifyFavorites(galleryDetail.galleryInfo)
}.onSuccess { add ->
if (add) {
showSnackbar(addSucceed)
} else {
showSnackbar(removeSucceed)
}
}.onFailure {
// TODO: We don't know if it's add or remove
showSnackbar(addFailed)
}
}
}
},
) {
Icon(
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
contentDescription = null,
)
}
Text(text = favButtonText)
}
EhIconButton(
icon = Icons.Default.Search,
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tags.getArtistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_NORMAL,
mKeyword = "\"" + keyword + "\"",
).asDst(),
)
} else if (artistTag != null) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_TAG,
mKeyword = artistTag,
).asDst(),
)
} else if (null != galleryDetail.uploader) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_UPLOADER,
mKeyword = galleryDetail.uploader,
).asDst(),
)
}
},
)
EhIconButton(
icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.search_cover),
onClick = {
val key = galleryDetail.thumbKey!!
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = key.substringAfterLast('/').substringBefore('-'),
).asDst(),
)
},
)
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
val permissionDenied = stringResource(R.string.permission_denied)
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
val noTorrents = stringResource(R.string.no_torrents)
val torrentResult = remember(galleryDetail) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, galleryDetail.gid, galleryDetail.token)
}
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
)
}
val url = selected.url
val name = "${selected.name}.torrent"
val r = android.app.DownloadManager.Request(url.toUri())
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
downloadManager.enqueue(r)
showSnackbar(downloadTorrentStarted)
}
}
EhIconButton(
icon = Icons.Default.SwapVerticalCircle,
text = torrentText,
onClick = {
launchIO {
if (galleryDetail.torrentCount > 0) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
runSuspendCatching {
showTorrentDialog()
}.onFailure {
logcat(it)
showSnackbar(downloadTorrentFailed)
}
} else {
showSnackbar(permissionDenied)
}
} else {
showSnackbar(noTorrents)
}
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
}
val rateSucceed = stringResource(R.string.rate_successfully)
val rateFailed = stringResource(R.string.rate_failed)
val signInFirst = stringResource(R.string.sign_in_first)
fun showRateDialog() {
launchIO {
if (galleryDetail.apiUid < 0) {
showSnackbar(signInFirst)
return@launchIO
}
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.size(keylineMargin))
GalleryRatingBar(
rating = expectedValue,
onRatingChange = {
expectedValue = it.coerceAtLeast(.5f)
text = getRatingText(expectedValue)
},
)
}
}
galleryDetail.runSuspendCatching {
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
}.onSuccess { result ->
galleryDetail.apply {
rating = result.rating
ratingCount = result.ratingCount
}
ratingText = getAllRatingText(result.rating, result.ratingCount)
showSnackbar(rateSucceed)
}.onFailure {
logcat(it)
showSnackbar(rateFailed)
}
}
}
CrystalCard(onClick = ::showRateDialog) {
Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GalleryDetailRating(rating = galleryDetail.rating)
Spacer(modifier = Modifier.size(keylineMargin))
Text(text = ratingText)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
val tags = galleryDetail.tags
if (tags.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(text = stringResource(id = R.string.no_tags))
}
} else {
val copy = stringResource(android.R.string.copy)
val copyTrans = stringResource(R.string.copy_trans)
val showDefine = stringResource(R.string.show_definition)
val addFilter = stringResource(R.string.add_filter)
val filterAdded = stringResource(R.string.filter_added)
val upTag = stringResource(R.string.tag_vote_up)
val downTag = stringResource(R.string.tag_vote_down)
GalleryTags(
tagGroups = tags,
onTagClick = {
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_TAG, mKeyword = it).asDst())
},
onTagLongClick = { translated, tag ->
val index = tag.indexOf(':')
val temp = if (index >= 0) {
tag.substring(index + 1)
} else {
tag
}
launchIO {
awaitSelectAction {
onSelect(copy) {
addTextToClipboard(tag)
}
if (temp != translated) {
onSelect(copyTrans) {
addTextToClipboard(translated)
}
}
onSelect(showDefine) {
openBrowser(EhUrl.getTagDefinitionUrl(temp))
}
onSelect(addFilter) {
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
Filter(FilterMode.TAG, tag).remember()
showSnackbar(filterAdded)
}
if (galleryDetail.apiUid >= 0) {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
}()
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
if (Settings.showComments) {
GalleryDetailComment(galleryDetail.comments.comments)
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
}
}
@StringRes
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
for (tagGroup in this) {
if (tagGroup.isNotEmpty()) {
val namespace = tagGroup.groupName
if (namespace == TagNamespace.Artist.value || namespace == TagNamespace.Cosplayer.value) {
return "$namespace:${tagGroup[0].removePrefix("_")}"
}
}
}
return null
}
@Composable
private fun Context.collectPreviewItems(detail: GalleryDetail, prefetchDistance: Int) = rememberInVM {
val pageSize = detail.previewList.size
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
Pager(
PagingConfig(
pageSize = pageSize,
prefetchDistance = prefetchDistance,
initialLoadSize = pageSize,
jumpThreshold = 2 * pageSize,
),
) {
object : PagingSource<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
val key = params.key ?: 0
val up = getOffset(params, key, pages)
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
detail.runSuspendCatching {
(up..end).filterNot { it in previewPagesMap }.map { it / pageSize }.toSet()
.parMap(concurrency = Settings.multiThreadDownload) { page ->
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
EhEngine.getPreviewList(url).first
}.flattenForEach {
previewPagesMap[it.position] = it
if (Settings.preloadThumbAggressively) {
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
}
}
}.foldToLoadResult {
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
val prevK = if (up <= 0 || r.isEmpty()) null else up
val nextK = if (end == pages - 1) null else end + 1
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
}
}
override val jumpingSupported = true
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
private fun LazyGridScope.galleryPreview(data: LazyPagingItems<GalleryPreview>, onClick: (Int) -> Unit) {
items(
count = data.itemCount,
key = data.itemKey(key = { item -> item.position }),
contentType = { "preview" },
) { index ->
val item = data[index]
EhPreviewItem(item, index) { onClick(index) }
}
}
@@ -1,171 +1,92 @@
package com.hippo.ehviewer.ui.screen
import android.Manifest
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.os.Parcelable
import android.text.TextUtils.TruncateAt.END
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.StringRes
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SwapVerticalCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import coil3.imageLoader
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.asGalleryDetail
import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.confirmRemoveDownload
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.ArchiveList
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
import com.hippo.ehviewer.ui.main.TorrentList
import com.hippo.ehviewer.ui.modifyFavorites
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberLambda
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.AppHelper
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission
import com.hippo.files.delete
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
@@ -184,47 +105,20 @@ data class TokenArgs(
val page: Int = 0,
) : GalleryDetailScreenArgs
@StringRes
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
val namespace = TagNamespace.Artist.value
for (tagGroup in this) {
if (tagGroup.groupName == namespace && tagGroup.size > 0) {
return "$namespace:${tagGroup[0].removePrefix("_")}"
}
}
return null
}
@Destination<RootGraph>
@Composable
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
var galleryInfo by remember {
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
var galleryInfo by rememberInVM {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
val (gid, token) = remember {
when (args) {
is GalleryInfoArgs -> args.galleryInfo.run { gid to token }
is GalleryInfoArgs -> with(args.galleryInfo) { gid to token }
is TokenArgs -> args.gid to args.token
}
}
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
val activity = remember { findActivity<MainActivity>() }
var showReadAction by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(args, galleryInfo) {
if (showReadAction) {
@@ -243,15 +137,10 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
}
with(activity) {
ProvideAssistContent(galleryDetailUrl)
}
ProvideAssistContent(galleryDetailUrl)
var getDetailError by rememberSaveable { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val voteSuccess = stringResource(R.string.tag_vote_successfully)
val voteFailed = stringResource(R.string.vote_failed)
if (galleryInfo !is GalleryDetail && getDetailError.isBlank()) {
LaunchedEffect(Unit) {
val galleryDetail = galleryDetailCache[gid]
@@ -277,20 +166,6 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
if (result != null) {
showSnackbar(result)
} else {
showSnackbar(voteSuccess)
}
}.onFailure {
showSnackbar(voteFailed)
}
}
val archiveResult = remember(galleryInfo) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
val detail = galleryInfo as GalleryDetail
@@ -329,7 +204,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
val name = "$gid-${EhUtils.getSuitableTitle(galleryDetail)}.zip"
try {
activity.startActivity(intent)
startActivity(intent)
withUIContext { addTextToClipboard(name, true) }
} catch (_: ActivityNotFoundException) {
val r = DownloadManager.Request(uri)
@@ -363,586 +238,6 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
fun navigateToPreview(nextPage: Boolean = false) {
(galleryInfo as? GalleryDetail)?.let {
navigator.navigate(GalleryPreviewScreenDestination(it, nextPage))
}
}
fun LazyGridScope.galleryDetailPreview(gd: GalleryDetail) {
val previewList = gd.previewList
items(previewList) {
EhPreviewItem(
galleryPreview = it,
position = it.position,
onClick = { navToReader(gd.galleryInfo, it.position) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
val footerText = if (gd.previewPages <= 0 || previewList.isEmpty()) {
stringResource(R.string.no_previews)
} else if (gd.previewPages == 1) {
stringResource(R.string.no_more_previews)
} else {
stringResource(R.string.more_previews)
}
TextButton(onClick = ::navigateToPreview.partially1(true)) {
Text(footerText)
}
}
}
@Composable
fun BelowHeader(galleryDetail: GalleryDetail) {
@Composable
fun EhIconButton(
icon: ImageVector,
text: String,
onClick: () -> Unit,
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledTertiaryIconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
Text(text = text)
}
@Composable
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
val maxShowCount = 2
val commentText = when {
commentsList.isEmpty() -> stringResource(R.string.no_comments)
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
GalleryCommentCard(
modifier = Modifier.padding(vertical = 4.dp),
comment = item,
onCardClick = ::onNavigateToCommentScene,
onUserClick = ::onNavigateToCommentScene,
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
maxLines = 5
ellipsize = END
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = ::onNavigateToCommentScene),
contentAlignment = Alignment.Center,
) {
Text(commentText)
}
}
}
suspend fun showNewerVersionDialog() {
val items = galleryDetail.newerVersions.map {
getString(R.string.newer_version_title, it.title, it.posted)
}
val selected = awaitSelectItem(items)
val info = galleryDetail.newerVersions[selected]
withUIContext {
// Can't use GalleryInfoArgs as thumbKey is null
navigator.navigate(info.gid asDstWith info.token)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
if (galleryDetail.newerVersions.isNotEmpty()) {
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = { launchIO { showNewerVersionDialog() } },
modifier = Modifier.fillMaxWidth().height(32.dp),
) {
}
Text(text = stringResource(id = R.string.newer_version_available))
}
Spacer(modifier = Modifier.size(keylineMargin))
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
) {
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
val favButtonText = if (favSlot != NOT_FAVORITED) {
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
} else {
stringResource(id = R.string.not_favorited)
}
val favoritesLock = remember { MutatorMutex() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
val addSucceed = stringResource(R.string.add_to_favorite_success)
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
val addFailed = stringResource(R.string.add_to_favorite_failure)
FilledTertiaryIconToggleButton(
checked = favSlot != NOT_FAVORITED,
onCheckedChange = {
launchIO {
favoritesLock.mutate {
runSuspendCatching {
modifyFavorites(galleryDetail.galleryInfo)
}.onSuccess { add ->
if (add) {
showSnackbar(addSucceed)
} else {
showSnackbar(removeSucceed)
}
}.onFailure {
// TODO: We don't know if it's add or remove
showSnackbar(addFailed)
}
}
}
},
) {
Icon(
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
contentDescription = null,
)
}
Text(text = favButtonText)
}
EhIconButton(
icon = Icons.Default.Search,
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tags.getArtistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_NORMAL,
mKeyword = "\"" + keyword + "\"",
).asDst(),
)
} else if (artistTag != null) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_TAG,
mKeyword = artistTag,
).asDst(),
)
} else if (null != galleryDetail.uploader) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_UPLOADER,
mKeyword = galleryDetail.uploader,
).asDst(),
)
}
},
)
EhIconButton(
icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.search_cover),
onClick = {
val key = galleryDetail.thumbKey!!
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = key.substringAfterLast('/').substringBefore('-'),
).asDst(),
)
},
)
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
val permissionDenied = stringResource(R.string.permission_denied)
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
val noTorrents = stringResource(R.string.no_torrents)
val torrentResult = remember(galleryDetail) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
}
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
)
}
val url = selected.url
val name = "${selected.name}.torrent"
val r = DownloadManager.Request(url.toUri())
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
downloadManager.enqueue(r)
showSnackbar(downloadTorrentStarted)
}
}
EhIconButton(
icon = Icons.Default.SwapVerticalCircle,
text = torrentText,
onClick = {
launchIO {
if (galleryDetail.torrentCount > 0) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
runSuspendCatching {
showTorrentDialog()
}.onFailure {
logcat(it)
showSnackbar(downloadTorrentFailed)
}
} else {
showSnackbar(permissionDenied)
}
} else {
showSnackbar(noTorrents)
}
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
}
val rateSucceed = stringResource(R.string.rate_successfully)
val rateFailed = stringResource(R.string.rate_failed)
fun showRateDialog() {
launchIO {
if (galleryDetail.apiUid < 0) {
showSnackbar(signInFirst)
return@launchIO
}
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.size(keylineMargin))
GalleryRatingBar(
rating = expectedValue,
onRatingChange = {
expectedValue = it.coerceAtLeast(.5f)
text = getRatingText(expectedValue)
},
)
}
}
galleryDetail.runSuspendCatching {
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
}.onSuccess { result ->
galleryInfo = galleryDetail.apply {
rating = result.rating
ratingCount = result.ratingCount
}
ratingText = getAllRatingText(result.rating, result.ratingCount)
showSnackbar(rateSucceed)
}.onFailure {
logcat(it)
showSnackbar(rateFailed)
}
}
}
CrystalCard(onClick = ::showRateDialog) {
Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GalleryDetailRating(rating = galleryDetail.rating)
Spacer(modifier = Modifier.size(keylineMargin))
Text(text = ratingText)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
val tags = galleryDetail.tags
if (tags.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(text = stringResource(id = R.string.no_tags))
}
} else {
val copy = stringResource(android.R.string.copy)
val copyTrans = stringResource(R.string.copy_trans)
val showDefine = stringResource(R.string.show_definition)
val addFilter = stringResource(R.string.add_filter)
val filterAdded = stringResource(R.string.filter_added)
val upTag = stringResource(R.string.tag_vote_up)
val downTag = stringResource(R.string.tag_vote_down)
GalleryTags(
tagGroups = tags,
onTagClick = {
val lub = ListUrlBuilder()
lub.mode = ListUrlBuilder.MODE_TAG
lub.keyword = it
navigate(lub.asDst())
},
onTagLongClick = { translated, tag ->
val index = tag.indexOf(':')
val temp = if (index >= 0) {
tag.substring(index + 1)
} else {
tag
}
launchIO {
awaitSelectAction {
with(activity) {
onSelect(copy) {
addTextToClipboard(tag)
}
if (temp != translated) {
onSelect(copyTrans) {
addTextToClipboard(translated)
}
}
onSelect(showDefine) {
openBrowser(EhUrl.getTagDefinitionUrl(temp))
}
onSelect(addFilter) {
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
Filter(FilterMode.TAG, tag).remember()
showSnackbar(filterAdded)
}
if (galleryDetail.apiUid >= 0) {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
}
}()
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
if (Settings.showComments) {
GalleryDetailComment(galleryDetail.comments.comments)
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
}
}
@Composable
fun GalleryDetailContent(
galleryInfo: GalleryInfo,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val galleryDetail = galleryInfo.asGalleryDetail()
val windowSizeClass = LocalWindowSizeClass.current
val thumbColumns by Settings.thumbColumns.collectAsState()
val readText = stringResource(R.string.read)
val startPage by rememberInVM {
EhDB.getReadProgressFlow(galleryInfo.gid)
}.collectAsState(0)
val readButtonText = if (startPage == 0) {
readText
} else {
stringResource(R.string.read_from, startPage + 1)
}
val downloadState by EhDownloadManager.collectDownloadState(gid)
val downloadButtonText = when (downloadState) {
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
else -> error("Invalid DownloadState!!!")
}
fun onReadButtonClick() = navToReader(galleryInfo.findBaseInfo(), startPage)
fun onCategoryChipClick() {
val category = galleryInfo.category
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
return
}
val lub = ListUrlBuilder(category = category)
navigator.navigate(lub.asDst())
}
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
val lub = ListUrlBuilder()
lub.mode = ListUrlBuilder.MODE_UPLOADER
lub.keyword = uploader
navigator.navigate(lub.asDst())
}
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet && galleryDetail != null) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
) {
GalleryInfoBottomSheet(galleryDetail, navigator)
}
}
fun onGalleryInfoCardClick() {
showBottomSheet = true
}
val filterAdded = stringResource(R.string.filter_added)
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
launchIO {
awaitPermissionOrCancel {
Text(text = stringResource(R.string.filter_the_uploader, uploader))
}
Filter(FilterMode.UPLOADER, uploader).remember()
showSnackbar(filterAdded)
}
}
val onDownloadButtonClick = rememberLambda(galleryInfo) {
galleryDetail ?: return@rememberLambda
if (EhDownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
launchUI { startDownload(activity, false, galleryDetail.galleryInfo) }
} else {
launch { confirmRemoveDownload(galleryDetail) }
}
}
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onCategoryChipClick = ::onCategoryChipClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
)
Row {
FilledTonalButton(
onClick = onDownloadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = downloadButtonText, maxLines = 1)
}
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = readButtonText, maxLines = 1)
}
}
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (galleryDetail != null) {
galleryDetailPreview(galleryDetail)
}
}
WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
Row(
modifier = Modifier.fillMaxWidth(),
) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onCategoryChipClick = ::onCategoryChipClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = modifier.height(16.dp))
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = readButtonText, maxLines = 1)
}
Spacer(modifier = modifier.height(24.dp))
FilledTonalButton(
onClick = onDownloadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = downloadButtonText, maxLines = 1)
}
}
}
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (galleryDetail != null) {
galleryDetailPreview(galleryDetail)
}
}
}
}
Scaffold(
topBar = {
LargeTopAppBar(
@@ -973,7 +268,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
IconButton(
onClick = {
AppHelper.share(activity, galleryDetailUrl)
AppHelper.share(implicit<MainActivity>(), galleryDetailUrl)
// In case the link is copied to the clipboard
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
},
@@ -1033,7 +328,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
dropdown = false
val gd = galleryInfo as? GalleryDetail ?: return@DropdownMenuItem
launchIO {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
title = R.string.clear_image_cache,
) {
@@ -1064,7 +359,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
val downloadInfo = EhDownloadManager.getDownloadInfo(gid)
val canExport = downloadInfo?.state == DownloadInfo.STATE_FINISH
if (!canExport) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
showCancelButton = false,
text = { Text(text = stringResource(id = R.string.download_gallery_first)) },
)
@@ -1105,6 +400,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
GalleryDetailContent(
galleryInfo = gi,
contentPadding = it,
getDetailError = getDetailError,
onRetry = { getDetailError = "" },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
)
} else if (getDetailError.isNotBlank()) {
@@ -1119,3 +416,18 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
}
context(Context, SnackbarHostState)
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
if (result != null) {
showSnackbar(result)
} else {
showSnackbar(getString(R.string.tag_vote_successfully))
}
}.onFailure {
showSnackbar(getString(R.string.vote_failed))
}
}
@@ -5,6 +5,7 @@ import android.net.Uri
import android.view.ViewConfiguration
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -22,7 +23,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -124,7 +124,9 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.SearchFilter
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
@@ -133,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.pickVisualMedia
import com.hippo.ehviewer.util.sha1
@@ -155,23 +158,23 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Destination<RootGraph>
@Composable
fun HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
fun AnimatedVisibilityScope.HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
@Destination<RootGraph>
@Composable
fun SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
fun AnimatedVisibilityScope.SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
@Destination<RootGraph>
@Composable
fun WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
fun AnimatedVisibilityScope.WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
@Destination<RootGraph>
@Composable
fun ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
fun AnimatedVisibilityScope.ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
@Destination<RootGraph>
@Composable
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
val searchFieldState = rememberTextFieldState()
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
@@ -281,7 +284,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
ProvideSideSheetContent { sheetState ->
TopAppBar(
title = { Text(text = stringResource(id = R.string.toplist)) },
windowInsets = WindowInsets(0),
windowInsets = EmptyWindowInsets,
)
toplists.forEach { (name, keyword) ->
ListItem(
@@ -305,7 +308,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
actions = {
IconButton(onClick = {
launch {
awaitPermissionOrCancel(title = R.string.quick_search, showCancelButton = false) {
awaitConfirmationOrCancel(title = R.string.quick_search, showCancelButton = false) {
Text(text = stringResource(id = R.string.add_quick_search_tip))
}
}
@@ -362,7 +365,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
)
}
},
windowInsets = WindowInsets(0),
windowInsets = EmptyWindowInsets,
)
Box(modifier = Modifier.fillMaxSize()) {
val quickSearchListState = rememberLazyListState()
@@ -372,21 +375,25 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = quickSearchListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
val index by rememberUpdatedState(itemIndex)
ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging ->
ReorderableItem(
reorderableLazyListState,
item.id!!,
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
) { isDragging ->
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_quick_search, item.name))
}
}.onSuccess {
@@ -467,7 +474,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}
}
}
Deferred({ delay(200) }) {
Await({ delay(200) }) {
if (quickSearchList.isEmpty()) {
Text(
text = stringResource(id = R.string.quick_search_tip),
@@ -546,8 +553,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
fabHidden = it
},
title = suitableTitle,
searchFieldState = searchFieldState,
searchFieldHint = searchBarHint,
searchFieldState = searchFieldState,
suggestionProvider = {
GalleryDetailUrlParser.parse(it, false)?.run {
GalleryDetailUrlSuggestion(gid, token)
@@ -1,136 +0,0 @@
package com.hippo.ehviewer.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import arrow.fx.coroutines.parMap
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
import com.hippo.ehviewer.ui.tools.getLimit
import com.hippo.ehviewer.ui.tools.getOffset
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.flattenForEach
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import moe.tarsin.coroutines.runSuspendCatching
@Destination<RootGraph>
@Composable
fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
val pgSize = detail.previewList.size
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
val thumbColumns by Settings.thumbColumns.collectAsState()
val data = rememberInVM {
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
Pager(
PagingConfig(
pageSize = pgSize,
prefetchDistance = thumbColumns,
initialLoadSize = pgSize,
jumpThreshold = 2 * pgSize,
),
) {
object : PagingSource<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
val key = params.key ?: 0
val up = getOffset(params, key, pages)
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
detail.runSuspendCatching {
(up..end).filterNot { it in previewPagesMap }.map { it / pgSize }.toSet()
.parMap(concurrency = Settings.multiThreadDownload) { page ->
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
EhEngine.getPreviewList(url).first
}.flattenForEach {
previewPagesMap[it.position] = it
if (Settings.preloadThumbAggressively) {
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
}
}
}.foldToLoadResult {
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
val prevK = if (up <= 0 || r.isEmpty()) null else up
val nextK = if (end == pages - 1) null else end + 1
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
}
}
override val jumpingSupported = true
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.gallery_previews)) },
navigationIcon = {
IconButton(onClick = { popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
},
scrollBehavior = scrollBehaviour,
)
},
) { paddingValues ->
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
state = state,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
count = data.itemCount,
key = data.itemKey(key = { item -> item.position }),
contentType = data.itemContentType(),
) { index ->
val item = data[index]
EhPreviewItem(item, index) { navToReader(detail.galleryInfo, index) }
}
}
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.screen
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -52,7 +53,7 @@ import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.doGalleryInfoAction
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.thenIf
@@ -66,7 +67,7 @@ import kotlinx.coroutines.launch
@Destination<RootGraph>
@Composable
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
val title = stringResource(id = R.string.history)
val hint = stringResource(R.string.search_bar_hint, title)
val animateItems by Settings.animateItems.collectAsState()
@@ -101,7 +102,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
trailingIcon = {
IconButton(onClick = {
launch {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
)
@@ -164,7 +165,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
}
Deferred(keyword, { delay(200) }) {
Await(keyword, { delay(200) }) {
if (historyData.itemCount == 0) {
Column(
modifier = Modifier.padding(paddingValues).padding(horizontal = marginH).fillMaxSize(),
@@ -38,7 +38,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SearchBarHorizontalPadding
import androidx.compose.material3.SearchBarInputField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -100,9 +99,9 @@ fun SearchBarScreen(
onApplySearch: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
title: String? = null,
title: String?,
searchFieldHint: String,
searchFieldState: TextFieldState = rememberTextFieldState(),
searchFieldHint: String? = null,
suggestionProvider: SuggestionProvider? = null,
tagNamespace: Boolean = false,
searchBarOffsetY: () -> Int = { 0 },
@@ -190,7 +189,7 @@ fun SearchBarScreen(
fun deleteKeyword(keyword: String) {
scope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.delete,
) {
Text(text = stringResource(id = R.string.delete_search_history, keyword))
@@ -226,14 +225,10 @@ fun SearchBarScreen(
expanded = expanded,
onExpandedChange = onExpandedChange,
modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2),
label = title.ifNotNullThen {
Text(title!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
}.takeUnless {
placeholder = {
val contentActive by activeState.state
expanded || contentActive || searchFieldState.text.isNotEmpty()
},
placeholder = searchFieldHint.ifNotNullThen {
Text(searchFieldHint!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
val text = title.takeUnless { expanded || contentActive } ?: searchFieldHint
Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1)
},
leadingIcon = {
if (expanded) {
@@ -318,3 +313,4 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (ke
}
private val WhitespaceRegex = Regex("\\s+")
private val SearchBarHorizontalPadding = 16.dp
@@ -139,7 +139,7 @@ fun AboutScreen(navigator: DestinationsNavigator) {
}
suspend fun DialogState.showNewVersion(context: Context, release: Release) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.download,
title = R.string.new_version_available,
) {
@@ -5,7 +5,10 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -35,12 +38,11 @@ import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.ReadableTime
@@ -48,14 +50,16 @@ import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getAppLanguage
import com.hippo.ehviewer.util.getLanguages
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastV
import com.hippo.ehviewer.util.sendTo
import com.hippo.ehviewer.util.setAppLanguage
import com.hippo.files.delete
import com.hippo.files.toOkioPath
import com.jamal.composeprefs3.ui.prefs.DropDownPref
import com.jamal.composeprefs3.ui.prefs.SwitchPref
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import java.util.zip.ZipEntry
@@ -64,6 +68,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.init.appCtx
@Destination<RootGraph>
@Composable
@@ -73,7 +79,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
val dialogState = LocalDialogState.current
Scaffold(
topBar = {
TopAppBar(
@@ -89,17 +94,31 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
if (AppConfig.isSnapshot) {
SwitchPreference(
title = "New Compose Reader [WIP!!!]",
value = Settings::newReader,
)
}
SwitchPreference(
title = stringResource(id = R.string.settings_advanced_save_parse_error_body),
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
value = Settings::saveParseErrorBody,
)
val stripAds = Settings.stripExtraneousAds.asMutableState()
SwitchPreference(
title = stringResource(id = R.string.settings_block_extraneous_ads),
value = stripAds.rememberedAccessor,
)
AnimatedVisibility(visible = stripAds.value) {
LauncherPreference(
title = stringResource(id = R.string.settings_ads_placeholder),
contract = ActivityResultContracts.PickVisualMedia(),
key = PickVisualMediaRequest(mediaType = ImageOnly),
) { uri ->
withIOContext {
if (uri != null) {
uri.toOkioPath() sendTo AdsPlaceholderFile
} else {
AdsPlaceholderFile.delete()
}
}
}
}
SwitchPreference(
title = stringResource(id = R.string.settings_advanced_save_crash_log),
summary = stringResource(id = R.string.settings_advanced_save_crash_log_summary),
@@ -177,26 +196,17 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.animate_items),
summary = stringResource(id = R.string.animate_items_summary),
)
var userAgent by Settings::userAgent.observed
val userAgentTitle = stringResource(id = R.string.user_agent)
Preference(
title = userAgentTitle,
summary = userAgent,
) {
coroutineScope.launch {
userAgent = dialogState.awaitInputText(
initial = userAgent,
title = userAgentTitle,
).trim().ifBlank { null } ?: CHROME_USER_AGENT
}
}
SwitchPreference(
title = stringResource(id = R.string.desktop_site),
summary = stringResource(id = R.string.desktop_site_summary),
value = Settings::desktopSite,
)
val exportFailed = stringResource(id = R.string.settings_advanced_export_data_failed)
val now = ReadableTime.getFilenamableTime()
LauncherPreference(
title = stringResource(id = R.string.settings_advanced_export_data),
summary = stringResource(id = R.string.settings_advanced_export_data_summary),
contract = ActivityResultContracts.CreateDocument("application/vnd.sqlite3"),
key = if (isAtLeastV) now else "$now.db",
contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
key = ReadableTime.getFilenamableTime() + ".db",
) { uri ->
uri?.let {
context.runCatching {
@@ -215,7 +225,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_advanced_import_data),
summary = stringResource(id = R.string.settings_advanced_import_data_summary),
contract = ActivityResultContracts.GetContent(),
key = if (isAtLeastV) "application/vnd.sqlite3" else "application/octet-stream",
key = "application/octet-stream",
) { uri ->
uri?.let {
context.runCatching {
@@ -290,3 +300,5 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
}
}
}
val AdsPlaceholderFile = appCtx.filesDir.toOkioPath() / "AdsPlaceholder"
@@ -5,6 +5,7 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.provider.DocumentsContract
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -63,7 +64,6 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
@@ -72,7 +72,7 @@ import splitties.init.appCtx
@Destination<RootGraph>
@Composable
fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
fun launchSnackBar(content: String) = launch { showSnackbar(content) }
Scaffold(
@@ -98,10 +98,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
val path = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)).toOkioPath()
check(path.isDirectory) { "$path is not a directory" }
keepNoMediaFileStatus(path) // Check if the directory is writable
downloadLocationState = path
launchNonCancellable {
keepNoMediaFileStatus(downloadLocationState)
}
}.onFailure {
logcat(it)
launchSnackBar(cannotGetDownloadLocation)
@@ -259,7 +257,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
val list = downloadLocation.list().filter(::isRedundant)
if (list.isNotEmpty()) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.delete,
title = R.string.settings_download_clean_redundancy,
) {
@@ -115,7 +115,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
) {
coroutineScope.launch {
val cookies = EhCookieStore.getIdentityCookies()
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.settings_eh_sign_out,
dismissText = R.string.settings_eh_clear_igneous,
showCancelButton = cookies.last().second != null,
@@ -160,7 +160,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
var error by rememberSaveable { mutableStateOf<String?>(null) }
val summary by rememberUpdatedState(
result?.run {
stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
when (limits.maximum) {
-1 -> stringResource(id = R.string.image_limits_restricted)
0 -> stringResource(id = R.string.image_limits_normal)
else -> stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
}
} ?: placeholder,
)
suspend fun getImageLimits() {
@@ -181,7 +185,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
summary = summary,
) {
coroutineScope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.reset,
title = R.string.image_limits,
confirmButtonEnabled = result?.run { limits.resetCost != 0 } ?: false,
@@ -190,12 +194,16 @@ fun EhScreen(navigator: DestinationsNavigator) {
Text(text = it)
} ?: result?.let { (limits, funds) ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { limits.current.toFloat() / limits.maximum },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
if (limits.maximum > 0) {
LinearProgressIndicator(
progress = { limits.current.toFloat() / limits.maximum },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
}
Text(text = summary)
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
if (limits.resetCost > 0) {
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
}
Text(text = stringResource(id = R.string.current_funds))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -333,11 +341,6 @@ fun EhScreen(navigator: DestinationsNavigator) {
entryValueRes = R.array.thumb_resolution_entry_values,
value = thumbResolution,
)
SwitchPreference(
title = stringResource(id = R.string.settings_eh_force_eh_thumb),
summary = stringResource(id = R.string.settings_eh_force_eh_thumb_summary),
value = Settings::forceEhThumb,
)
var showGalleryPages by Settings.showGalleryPages.asMutableState()
SwitchPref(
checked = showGalleryPages,
@@ -22,13 +22,13 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -58,7 +58,7 @@ import com.hippo.ehviewer.client.EhFilter.trigger
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.thenIf
import com.ramcosta.composedestinations.annotation.Destination
@@ -127,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
readOnly = true,
value = type,
onValueChange = {},
@@ -193,7 +193,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
actions = {
IconButton(onClick = {
scope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
title = R.string.filter,
showCancelButton = false,
) {
@@ -213,7 +213,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
}
},
) { paddingValues ->
Deferred({ allFilterMap.await() }) { filters ->
Await({ allFilterMap.await() }) { filters ->
LazyColumn(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentPadding = paddingValues,
@@ -251,7 +251,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
IconButton(
onClick = {
scope.launch {
dialogState.awaitPermissionOrCancel(confirmText = R.string.delete) {
dialogState.awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(id = R.string.delete_filter, filter.text))
}
filter.forget {
@@ -89,7 +89,7 @@ fun PrivacyScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.clear_search_history_summary),
) {
coroutineScope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
title = R.string.clear_search_history_confirm,
)
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.scrollbar.LocalScrollbarStyle
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -19,8 +18,7 @@ import com.hippo.ehviewer.ui.tools.scrollbarStyle
import com.hippo.ehviewer.util.isAtLeastS
@Composable
fun EhTheme(content: @Composable () -> Unit) {
val useDarkTheme = isSystemInDarkTheme()
fun EhTheme(useDarkTheme: Boolean, content: @Composable () -> Unit) {
val amoled by Settings.blackDarkTheme.collectAsState()
val context = LocalContext.current
val colors = if (useDarkTheme) {
@@ -0,0 +1,36 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import arrow.core.None
import arrow.core.Option
import arrow.core.some
@Composable
fun <T> Await(
block: suspend () -> T,
placeholder: (@Composable () -> Unit)? = null,
content: @Composable (T) -> Unit,
) {
var completed by remember(block) { mutableStateOf<Option<T>>(None) }
LaunchedEffect(block) {
completed = block().some()
}
if (placeholder != null) {
completed.onNone { placeholder() }
}
completed.onSome { content(it) }
}
@Composable
fun <T> Await(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
var completed by remember(key, block) { mutableStateOf<Option<T>>(None) }
LaunchedEffect(key, block) {
completed = block().some()
}
completed.onSome { content(it) }
}
@@ -1,26 +0,0 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun <T> Deferred(block: suspend () -> T, content: @Composable (T) -> Unit) {
var completed by remember(block) { mutableStateOf<T?>(null) }
LaunchedEffect(block) {
completed = block()
}
completed?.let { content(it) }
}
@Composable
fun <T> Deferred(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
var completed by remember(key, block) { mutableStateOf<T?>(null) }
LaunchedEffect(key, block) {
completed = block()
}
completed?.let { content(it) }
}
@@ -50,12 +50,14 @@ import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -77,6 +79,7 @@ import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -114,7 +117,6 @@ class DialogState {
invalidator: (suspend Raise<String>.(R) -> Unit)? = null,
block: @Composable DialogScope<R>.(String?) -> Unit,
): R = dialog { cont ->
val coroutineScope = rememberCoroutineScope()
val state = remember(cont) { mutableStateOf(initial) }
var errorMsg by remember(cont) { mutableStateOf<String?>(null) }
val impl = remember(cont) {
@@ -122,17 +124,19 @@ class DialogState {
override var expectedValue by state
}
}
if (invalidator != null) {
LaunchedEffect(state) {
snapshotFlow { state.value }.collectLatest {
errorMsg = either { invalidator(it) }.leftOrNull()
}
}
}
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
if (invalidator == null || errorMsg == null) {
cont.resume(state.value)
} else {
coroutineScope.launch {
errorMsg = either { invalidator(state.value) }.leftOrNull()
errorMsg ?: cont.resume(state.value)
}
}
}) {
Text(text = stringResource(id = android.R.string.ok))
@@ -156,13 +160,17 @@ class DialogState {
hint: String? = null,
isNumber: Boolean = false,
@StringRes confirmText: Int = android.R.string.ok,
onUserDismiss: (() -> Unit)? = null,
invalidator: (suspend Raise<String>.(String) -> Unit)? = null,
) = dialog { cont ->
val coroutineScope = rememberCoroutineScope()
var state by remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { cont.cancel() },
onDismissRequest = {
cont.cancel()
onUserDismiss?.invoke()
},
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
@@ -264,7 +272,7 @@ class DialogState {
)
}
suspend fun awaitPermissionOrCancel(
suspend fun awaitConfirmationOrCancel(
@StringRes confirmText: Int = android.R.string.ok,
@StringRes dismissText: Int = android.R.string.cancel,
@StringRes title: Int? = null,
@@ -451,7 +459,7 @@ class DialogState {
style = MaterialTheme.typography.headlineSmall,
)
}
LazyColumn {
FastScrollLazyColumn {
itemsIndexed(items) { index, text ->
CheckableItem(
text = text,
@@ -487,7 +495,7 @@ class DialogState {
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.headlineSmall,
)
LazyColumn {
FastScrollLazyColumn {
itemsIndexed(items) { index, text ->
CheckableItem(
text = text,
@@ -522,7 +530,7 @@ class DialogState {
leadingContent = {
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
},
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
@@ -603,7 +611,7 @@ private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = M
trailingContent = checked.ifTrueThen {
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
},
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -1,10 +1,10 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -29,10 +29,10 @@ fun DropdownFilterChip(
label = {
Text(text = if (selectedItemIndex == 0) label else menuItems[selectedItemIndex])
},
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchAnchorWidth = false) {
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchTextFieldWidth = false) {
menuItems.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(text = item) },
@@ -1,7 +1,6 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@@ -11,6 +10,8 @@ import androidx.compose.foundation.scrollbar.rememberScrollbarAdapter
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@Composable
@@ -24,7 +25,7 @@ fun FastScrollLazyColumn(
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit,
) {
Box {
Layout({
LazyColumn(
modifier = modifier,
state = state,
@@ -38,8 +39,13 @@ fun FastScrollLazyColumn(
VerticalScrollbar(
adapter = rememberScrollbarAdapter(state),
isScrollInProgress = state.isScrollInProgress,
modifier = Modifier.align(Alignment.TopEnd),
contentPadding = contentPadding,
)
}) { measurables, constraints ->
val (column, scrollbar) = measurables.map { it.measure(constraints) }
layout(column.width, column.height) {
column.place(IntOffset.Zero)
scrollbar.placeRelative(column.width - scrollbar.width, 0)
}
}
}
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more