Compare commits

..
50 Commits
Author SHA1 Message Date
AntsyLich feadad5e1e Release v0.21.3 2026-10-26 03:33:28 +06:00
AntsyLich fbcbe40714 Keep tab badges visible when the tab label is too long
Fixes #4165
Fixes #3423
Closes #3424

(cherry picked from commit b19c8abeba)
2026-10-26 03:33:28 +06:00
AntsyLich ab1f749289 Keep the installed extension when replacing it fails
Co-authored-by: hamenot <hamenot@users.noreply.github.com>
(cherry picked from commit cdc7d69c92)
2026-10-26 03:33:28 +06:00
AntsyLich f59dabd4d7 Decode only needed backup fields and restore entries in one pass
Closes #4168

(cherry picked from commit 3ed83a8d32)
2026-10-26 03:33:28 +06:00
AntsyLich e8f2c3f988 Fix Sui not being detected as a Shizuku provider
Closes #3785

(cherry picked from commit bd2ada2e82)
2026-10-26 03:33:28 +06:00
Mend Renovate ca8ffacd10 Update dependency org.conscrypt:conscrypt-android to v2.8.0 (#4173)
(cherry picked from commit 4310a224d4)
2026-10-26 02:45:30 +06:00
Mend Renovate 036a69c089 Update dependency com.google.firebase:firebase-bom to v35.1.0 (#4172)
(cherry picked from commit ab5f6585bc)
2026-10-26 02:45:30 +06:00
Mend Renovate 335c50c92e Update dependency androidx.webkit:webkit to v1.18.0 (#4171)
(cherry picked from commit e48fc8711f)
2026-10-26 02:45:30 +06:00
Mend Renovate 9e3f4c0fec Update okhttp to v5.5.1 (#4170)
(cherry picked from commit c8634ed089)
2026-10-26 02:45:30 +06:00
Mend Renovate 0cfc078b00 Update dependency androidx.compose:compose-bom-beta to v2026.10.01 (#4169)
(cherry picked from commit 8b8ee26af3)
2026-10-26 02:45:30 +06:00
AntsyLich 52c348f32b Move the download queue list to Compose
(cherry picked from commit d001e6f210)
2026-10-26 02:45:30 +06:00
AntsyLich 42474cd46e Fix the More tab icon's exit animation
(cherry picked from commit 2ca6961e1c)
2026-10-26 02:45:17 +06:00
MajorTanya 8780f1ca8a Redact some sensitive headers in logcat (#4156)
(cherry picked from commit 0864ce0f46)
2026-10-26 02:45:17 +06:00
AntsyLich b95886a00d Add missing changelog entries
(cherry picked from commit 4493166985)
2026-10-26 02:45:17 +06:00
AntsyLich e5b44d4d34 Update the sources with the most entries first in library updates
(cherry picked from commit b8070cdc9c)
2026-10-26 02:45:16 +06:00
AntsyLich 7e930f1afb Update dependency com.gradleup.tapmoc:tapmoc-gradle-plugin to v0.5.0
Closes #4133

Co-authored-by: Mend Renovate <bot@renovateapp.com>
(cherry picked from commit 5432ae74be)
2026-10-26 02:45:16 +06:00
AntsyLich 9158e4c9e6 Update markdown to v0.46.0
Closes #4134

Co-authored-by: Mend Renovate <bot@renovateapp.com>
(cherry picked from commit 90f6c8dcff)
2026-10-26 02:45:16 +06:00
AntsyLich 5f7dc2b81f Fix covers reloading when removing manga from library
(cherry picked from commit a424282840)
2026-10-26 02:45:16 +06:00
AntsyLich 2a5581df98 Remove the option to split tall images when downloading
The long strip reader decodes every page in strips of about 4MB since b585613, so splitting at
download time no longer helps it. The split also re-encoded the pieces as JPEG, turning PNG and WebP
pages lossy, and sized them for the screen of the device that downloaded the chapter.

(cherry picked from commit 8b9faa383b)
2026-10-26 02:45:16 +06:00
Mend Renovate ba70dd31e9 Update Gradle to v9.8.2 (#4148)
(cherry picked from commit 4750748dd1)
2026-10-26 02:44:25 +06:00
Mend Renovate f23a2c374d Update kotlinx.serialization to v1.12.0 (#4149)
(cherry picked from commit 6492feebe8)
2026-10-26 02:44:25 +06:00
Mend Renovate 1ed4c5a171 Update aboutLibraries to v15.3.0 (#4129)
(cherry picked from commit 1ff3a47e02)
2026-10-26 02:44:25 +06:00
Mend Renovate 7ea60402e5 Update actions/setup-java action to v6.1.0 (#4130)
(cherry picked from commit 18534bf477)
2026-10-26 02:44:25 +06:00
hosted-weblate[bot] a202c33707 Translations update from Hosted Weblate (#4135)
* Translated using Weblate (Basque)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eu/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eo/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Russian)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Japanese)

Currently translated at 96.5% (939 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (German)

Currently translated at 99.0% (964 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (German)

Currently translated at 99.6% (970 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (970 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Czech)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Arabic)

Currently translated at 93.5% (910 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.1% (965 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Arabic)

Currently translated at 93.5% (910 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

---------

Co-authored-by: Bartolumiu <bartolumiu@tr25.es>
Co-authored-by: Szafranek13 <szafranek13@proton.me>
Co-authored-by: Dexroneum <dexroneum@users.noreply.hosted.weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: Eduard Ereza Martínez <ereza@users.noreply.hosted.weblate.org>
Co-authored-by: MajorTanya <majortanya@users.noreply.hosted.weblate.org>
Co-authored-by: ZenVinny <zenvinny@users.noreply.hosted.weblate.org>
Co-authored-by: Adam Havránek <helak@users.noreply.hosted.weblate.org>
Co-authored-by: Mohamed kh <mohamedkhamekhami@gmail.com>
Co-authored-by: ZerOriSama <zerori@users.noreply.hosted.weblate.org>
(cherry picked from commit 2e72b50f52)
2026-10-26 02:44:25 +06:00
AntsyLich a805922392 Fix downloads not being found for sources sharing a stored source's name
(cherry picked from commit deeb9b2584)
2026-10-26 02:44:25 +06:00
AntsyLich 4df6a4705b Only send source headers to the source in WebView
(cherry picked from commit a01867dc3e)
2026-10-26 02:44:25 +06:00
AntsyLich f43a0f5d9d Fix WebView renderer being killed by sources setting an Origin header
(cherry picked from commit 5f63c983da)
2026-10-26 02:44:25 +06:00
Mend Renovate bffef9cb05 Update dependency com.google.firebase:firebase-bom to v35.0.2 (#4127)
(cherry picked from commit 83e8f917dc)
2026-10-26 02:44:25 +06:00
Mend Renovate 3f7f2a67c4 Update dependency io.kotest:kotest-assertions-core to v6.2.6 (#4128)
(cherry picked from commit 3058849b52)
2026-10-26 02:44:24 +06:00
MajorTanya eb0f35a7a5 Add Time Format setting (#4118)
Analogous to the Date Format setting.

Offers 4 modes:
- "Follow system" - based on the phone's time settings
  (which usually offer 12h/24h as a setting that may be toggled
  regardless of system language defaults)
  - this can be changed with an app switch which is why some callers
  are keyed on the 24-hour state.
- "Follow language" - uses the app language's default formats
  - e.g. English: 12-hour, German: 24-hour, etc.
- "24-hour": Forces 24-hour display
- "12-hour": Forces 12-hour display

(cherry picked from commit 970b12f1bd)
2026-10-26 02:44:24 +06:00
hosted-weblate[bot] f09e43efb2 Translations update from Hosted Weblate (#4095)
* Translated using Weblate (Amharic)

Currently translated at 27.5% (267 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/am/

* Translated using Weblate (Arabic)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

* Translated using Weblate (Belarusian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/be/

* Translated using Weblate (Belarusian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/be/

* Translated using Weblate (Bulgarian)

Currently translated at 75.5% (731 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 75.5% (731 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Bengali)

Currently translated at 84.6% (819 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bn/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (Cebuano)

Currently translated at 44.2% (428 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ceb/

* Translated using Weblate (Czech)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Czech)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Chuvash)

Currently translated at 64.4% (624 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cv/

* Translated using Weblate (Danish)

Currently translated at 32.1% (311 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/da/

* Translated using Weblate (German)

Currently translated at 97.8% (947 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (German)

Currently translated at 97.8% (947 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Greek)

Currently translated at 92.7% (898 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Esperanto)

Currently translated at 93.5% (906 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Spanish)

Currently translated at 92.7% (898 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Basque)

Currently translated at 62.0% (601 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eu/

* Translated using Weblate (Persian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (Persian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (Finnish)

Currently translated at 66.4% (643 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fi/

* Translated using Weblate (Filipino)

Currently translated at 98.5% (954 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Filipino)

Currently translated at 98.5% (954 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Galician)

Currently translated at 84.1% (815 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Hebrew)

Currently translated at 77.5% (751 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/he/

* Translated using Weblate (Hindi)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/

* Translated using Weblate (Hindi)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/

* Translated using Weblate (Croatian)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/

* Translated using Weblate (Hungarian)

Currently translated at 83.8% (812 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Indonesian)

Currently translated at 92.1% (892 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Italian)

Currently translated at 91.3% (884 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Japanese)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Javanese)

Currently translated at 34.5% (334 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/jv/

* Translated using Weblate (Georgian)

Currently translated at 42.5% (412 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ka/

* Translated using Weblate (Kazakh)

Currently translated at 70.1% (679 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kk/

* Translated using Weblate (Khmer (Central))

Currently translated at 21.3% (207 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/km/

* Translated using Weblate (Kannada)

Currently translated at 49.8% (483 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kn/

* Translated using Weblate (Korean)

Currently translated at 94.1% (911 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

* Translated using Weblate (Lithuanian)

Currently translated at 67.9% (658 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lt/

* Translated using Weblate (Latvian)

Currently translated at 74.1% (718 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lv/

* Translated using Weblate (Marathi)

Currently translated at 21.7% (211 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/mr/

* Translated using Weblate (Malay)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ms/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 77.5% (751 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nb/

* Translated using Weblate (Nepali)

Currently translated at 88.9% (861 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/

* Translated using Weblate (Dutch)

Currently translated at 92.8% (899 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 26.8% (260 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nn/

* Translated using Weblate (Polish)

Currently translated at 83.5% (809 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt/

* Translated using Weblate (Romanian)

Currently translated at 90.9% (880 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ro/

* Translated using Weblate (Russian)

Currently translated at 98.4% (953 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Russian)

Currently translated at 98.4% (953 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Sanskrit)

Currently translated at 57.2% (554 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sa/

* Translated using Weblate (Yakut)

Currently translated at 41.1% (398 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sah/

* Translated using Weblate (Sardinian)

Currently translated at 85.7% (830 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sc/

* Translated using Weblate (Kurdish (Southern))

Currently translated at 24.0% (233 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sdh/

* Translated using Weblate (Slovak)

Currently translated at 73.4% (711 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/

* Translated using Weblate (Slovak)

Currently translated at 73.4% (711 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/

* Translated using Weblate (Albanian)

Currently translated at 69.4% (672 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sq/

* Translated using Weblate (Serbian)

Currently translated at 77.6% (752 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sr/

* Translated using Weblate (Serbian)

Currently translated at 77.6% (752 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sr/

* Translated using Weblate (Swedish)

Currently translated at 92.6% (897 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sv/

* Translated using Weblate (Telugu)

Currently translated at 20.1% (195 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/te/

* Translated using Weblate (Thai)

Currently translated at 83.8% (812 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/th/

* Translated using Weblate (Turkish)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Ukrainian)

Currently translated at 84.6% (819 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uk/

* Translated using Weblate (Uzbek)

Currently translated at 35.5% (344 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uz/

* Translated using Weblate (Vietnamese)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 95.7% (927 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 95.7% (927 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Amharic)

Currently translated at 13.0% (3 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/am/

* Translated using Weblate (Bulgarian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bg/

* Translated using Weblate (Bengali)

Currently translated at 86.9% (20 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bn/

* Translated using Weblate (Cebuano)

Currently translated at 26.0% (6 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ceb/

* Translated using Weblate (Chuvash)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/cv/

* Translated using Weblate (Danish)

Currently translated at 17.3% (4 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/da/

* Translated using Weblate (Esperanto)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eo/

* Translated using Weblate (Basque)

Currently translated at 73.9% (17 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eu/

* Translated using Weblate (Finnish)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fi/

* Translated using Weblate (Galician)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/gl/

* Translated using Weblate (Hindi)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hi/

* Translated using Weblate (Hungarian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hu/

* Translated using Weblate (Italian)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/it/

* Translated using Weblate (Javanese)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/jv/

* Translated using Weblate (Georgian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ka/

* Translated using Weblate (Kazakh)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/kk/

* Translated using Weblate (Khmer (Central))

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/km/

* Translated using Weblate (Kannada)

Currently translated at 47.8% (11 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/kn/

* Translated using Weblate (Lithuanian)

Currently translated at 56.5% (13 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/lt/

* Translated using Weblate (Latvian)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/lv/

* Translated using Weblate (Marathi)

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/mr/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nb/

* Translated using Weblate (Nepali)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ne/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 17.3% (4 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nn/

* Translated using Weblate (Polish)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/

* Translated using Weblate (Portuguese)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt/

* Translated using Weblate (Sanskrit)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sa/

* Translated using Weblate (Yakut)

Currently translated at 30.4% (7 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sah/

* Translated using Weblate (Sardinian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sc/

* Translated using Weblate (Kurdish (Southern))

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sdh/

* Translated using Weblate (Slovak)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sk/

* Translated using Weblate (Albanian)

Currently translated at 60.8% (14 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sq/

* Translated using Weblate (Serbian)

Currently translated at 73.9% (17 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sr/

* Translated using Weblate (Telugu)

Currently translated at 4.3% (1 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/te/

* Translated using Weblate (Thai)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/th/

* Translated using Weblate (Ukrainian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/uk/

* Translated using Weblate (Uzbek)

Currently translated at 13.0% (3 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/uz/

* Translated using Weblate (Malayalam)

Currently translated at 23.3% (226 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ml/

* Translated using Weblate (Malayalam)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ml/

* Translated using Weblate (Assamese)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/as/

* Translated using Weblate (Burmese)

Currently translated at 27.0% (262 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/

* Translated using Weblate (Burmese)

Currently translated at 27.0% (262 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/

* Translated using Weblate (Tamil)

Currently translated at 90.0% (872 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ta/

* Translated using Weblate (French)

Currently translated at 92.5% (896 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fr/

* Translated using Weblate (Tatar)

Currently translated at 0.5% (5 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tt/

* Translated using Weblate (Kurdish (Kurmanji))

Currently translated at 26.6% (258 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kmr/

* Translated using Weblate (Kurdish (Kurmanji))

Currently translated at 26.6% (258 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kmr/

* Translated using Weblate (Azerbaijani)

Currently translated at 0.5% (5 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/az/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/

* Translated using Weblate (Indonesian)

Currently translated at 92.4% (895 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Indonesian)

Currently translated at 98.6% (955 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Bulgarian)

Currently translated at 76.0% (736 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.1% (960 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Bulgarian)

Currently translated at 81.4% (788 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Esperanto)

Currently translated at 94.6% (916 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Esperanto)

Currently translated at 95.1% (921 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Czech)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Esperanto)

Currently translated at 95.9% (929 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Bulgarian)

Currently translated at 80.8% (783 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Russian)

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ru/

* Translated using Weblate (Bulgarian)

Currently translated at 81.5% (789 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Italian)

Currently translated at 91.9% (890 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Russian)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Italian)

Currently translated at 92.5% (896 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Japanese)

Currently translated at 93.8% (908 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Japanese)

Currently translated at 95.4% (924 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Turkish)

Currently translated at 97.9% (948 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Turkish)

Currently translated at 99.7% (966 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Japanese)

Currently translated at 96.1% (931 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Japanese)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Korean)

Currently translated at 94.4% (914 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

---------

Co-authored-by: Anonymous <anonymous@users.noreply.hosted.weblate.org>
Co-authored-by: nadevko <me+oss@nadevko.cc>
Co-authored-by: Димитър Георгиев <dimitar13226@users.noreply.hosted.weblate.org>
Co-authored-by: Eduard Ereza Martínez <ereza@users.noreply.hosted.weblate.org>
Co-authored-by: Adam Havránek <helak@users.noreply.hosted.weblate.org>
Co-authored-by: MajorTanya <majortanya@users.noreply.hosted.weblate.org>
Co-authored-by: KING APPS <kingapps@users.noreply.hosted.weblate.org>
Co-authored-by: Infy Milliaria <infy-tls@users.noreply.hosted.weblate.org>
Co-authored-by: Throw Away <throwawayacc4gulshan@users.noreply.hosted.weblate.org>
Co-authored-by: Lucas Correia <temptingbadger@users.noreply.hosted.weblate.org>
Co-authored-by: ZenVinny <zenvinny@users.noreply.hosted.weblate.org>
Co-authored-by: Dexroneum <dexroneum@users.noreply.hosted.weblate.org>
Co-authored-by: KraXen <kraxen@users.noreply.hosted.weblate.org>
Co-authored-by: David Katrinka <davidkatrinka1995@users.noreply.hosted.weblate.org>
Co-authored-by: nay nai <naynaimiaonai@users.noreply.hosted.weblate.org>
Co-authored-by: abc0922001 <abc0922001@users.noreply.hosted.weblate.org>
Co-authored-by: Horace Johnson <horacejohnson99@users.noreply.hosted.weblate.org>
Co-authored-by: icarus suraci <1caru5@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: GpixeL <gamesfire313@users.noreply.hosted.weblate.org>
Co-authored-by: ZerOriSama <zerori@users.noreply.hosted.weblate.org>
Co-authored-by: Szafranek13 <szafranek13@proton.me>
Co-authored-by: Daniil Paschenko <daniilpaschenko@users.noreply.hosted.weblate.org>
Co-authored-by: Nicola Bortoletto <translate@nicolabortoletto.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: on9686 <on9686@users.noreply.hosted.weblate.org>
(cherry picked from commit 7618df898c)
2026-10-26 02:44:24 +06:00
Mend Renovate 92a3ae5c7f Update androidx.paging to v3.5.3 (#4099)
(cherry picked from commit 446f6df82c)
2026-10-26 02:44:24 +06:00
AntsyLich 2cf9536cb1 Stream entries when creating and restoring backups
Closes #3850

Co-authored-by: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com>
(cherry picked from commit 771ba33a33)
2026-10-26 02:44:24 +06:00
AntsyLich 9612d955db Release v0.21.2 2026-10-13 01:52:15 +06:00
AntsyLich 5ca2a39328 Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
(cherry picked from commit 5a5ff22c37)
2026-10-13 01:52:15 +06:00
AntsyLich fb2b224030 Fix some downloads not being recognized
(cherry picked from commit f0f1e4de5a)
2026-10-13 01:39:01 +06:00
AntsyLich ee454f3a82 Ignore long strip taps confirmed after the reader is gone
(cherry picked from commit c371d6331d)
2026-10-13 01:39:01 +06:00
AntsyLich 4fd0081817 Fail the install when nothing can confirm it instead of crashing
(cherry picked from commit 2df48e19a1)
2026-10-13 01:39:01 +06:00
AntsyLich 68dbd7a185 Show invalid filters from extensions instead of crashing
(cherry picked from commit c9e1b6e96e)
2026-10-13 01:39:01 +06:00
AntsyLich 7475f4554c Insert split pages only into the chapter they belong to
(cherry picked from commit 3156b4a1c4)
2026-10-13 01:39:00 +06:00
hazptr 8e688ed51e Fix tracker start date being shifted by the UTC offset (#4049)
Closes #1340
Closes #4049

(cherry picked from commit ee59394542)
2026-10-13 01:39:00 +06:00
AntsyLich cca6b5e2e7 Solve Cloudflare challenges of non-GET requests on the site's root
Closes #3909

(cherry picked from commit 7ae858e602)
2026-10-13 01:39:00 +06:00
AntsyLich 4c316ccc3a Update navigationevent to stop back gestures crashing closed screens
(cherry picked from commit 43de88c718)
2026-10-13 01:39:00 +06:00
AntsyLich 7c7392a533 Guard the database update against duplicates and damaged indexes
(cherry picked from commit ea2b73ad78)
2026-10-13 01:39:00 +06:00
MajorTanya d4fe03cd18 Don't send full Doubles to Comick for progress (#4090)
Previously, user could end up with progress displays like

35.019996643066406/100

because Mihon was sending raw Doubles/simply stringified Doubles for
progress data.

We now round to 2 decimal places & trim off any trailing zeroes such
that integers are sent as "1" instead of "1.0" while retaining "25.5"
and "41.02" as such.

(cherry picked from commit 1ca9b0efb8)
2026-10-13 01:39:00 +06:00
AntsyLich e9670d8a12 Release v0.21.1 2026-10-12 14:09:01 +06:00
AntsyLich 87e808548b Merge branch 'main' into 0.21.0
# Conflicts:
#	CHANGELOG.md
2026-10-12 14:04:00 +06:00
AntsyLich 79fc3a0a8a Release v0.21.0 2026-10-12 01:37:06 +06:00
AntsyLich c8b820a08f Regenerate baseline profiles 2026-10-12 01:37:05 +06:00
AntsyLich f45d0ad31d Remove the WebGPU reader and go back to the old image decoder
Brings back SSIV and the image decoder from v0.20.4 with their settings,
except "Use legacy decoder for long strip reader", which long strip
pages drawn as strips replace.
2026-10-12 01:14:22 +06:00
115 changed files with 3222 additions and 6965 deletions
-28
View File
@@ -11,34 +11,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff.
## [Unreleased]
### Added
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
### Changed
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
- Keep online chapters cached for a day instead of up to a size limit ([@AntsyLich](https://github.com/AntsyLich))
- Apply the reader's grayscale and inverted colors to pages only, not to transition, loading and error pages ([@AntsyLich](https://github.com/AntsyLich))
### Removed
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
### Fixed
- Fix extension settings showing duplicate entries after the app is restored from the background ([@AntsyLich](https://github.com/AntsyLich))
## [v0.21.3] - 2026-10-26
### Added
+1 -2
View File
@@ -273,6 +273,7 @@ dependencies {
implementation(libs.jsoup)
// Disk
implementation(libs.diskLruCache)
implementation(libs.unifile)
// Preferences
@@ -291,8 +292,6 @@ dependencies {
}
implementation(libs.image.decoder)
implementation(libs.webgpuviewer)
// UI libraries
implementation(libs.material)
implementation(libs.photoView)
@@ -59,6 +59,30 @@ inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
return fastFilter { !predicate(it) }
}
/**
* Splits the original collection into pair of lists,
* where *first* list contains elements for which [predicate] yielded `true`,
* while *second* list contains elements for which [predicate] yielded `false`.
*
* **Do not use for collections that come from public APIs**, since they may not support random
* access in an efficient way, and this method may actually be a lot slower. Only use for
* collections that are created by code we control and are known to support random access.
*/
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, List<T>> {
contract { callsInPlace(predicate) }
val first = ArrayList<T>()
val second = ArrayList<T>()
fastForEach {
if (predicate(it)) {
first.add(it)
} else {
second.add(it)
}
}
return Pair(first, second)
}
/**
* Returns the number of entries not matching the given [predicate].
*
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.util.system.GLUtil
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR
@@ -37,7 +38,12 @@ class BasePreferences(
PRIVATE(MR.strings.ext_installer_private, false),
}
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
"pref_hardware_bitmap_threshold",
GLUtil.SAFE_TEXTURE_LIMIT,
)
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
@@ -13,3 +13,13 @@ fun DotSeparatorText(
modifier = modifier,
)
}
@Composable
fun DotSeparatorNoSpaceText(
modifier: Modifier = Modifier,
) {
Text(
text = "•",
modifier = modifier,
)
}
@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
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
@@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.updatePadding
import ca.mpreg.webgpuviewer.renderer.GainmapInput
import ca.mpreg.webgpuviewer.renderer.Image
import ca.mpreg.webgpuviewer.viewer.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewer
import ca.mpreg.webgpuviewer.viewer.ImageViewerState
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Size
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.newDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Edit
@@ -79,9 +65,6 @@ fun MangaCoverDialog(
onEditClick: ((EditCoverAction) -> Unit)?,
onDismissRequest: () -> Unit,
) {
val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get()
val view = LocalView.current
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
@@ -168,59 +151,6 @@ fun MangaCoverDialog(
}
},
) { contentPadding ->
if (useNewRenderer) {
val state = remember(manga) { ImageViewerState() }
LaunchedEffect(manga) {
val request = ImageRequest.Builder(view.context)
.data(manga)
.size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true)
.build()
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
// Held, then freed, around the upload: its buffer alone doesn't keep
// the frame's native pixels alive.
val page = res.frame.use {
withContext(Dispatchers.Default) {
ImagePage.ImageSingle(
Image(
res.image,
res.width,
res.height,
createMipMaps = true,
backgroundColor = 0,
hdr = res.isHdr,
hdrHeadroom = res.hdrHeadroom,
gainmap = res.gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
},
),
)
}
}
state.apply {
fetchPage = { index ->
if (index == 0) page else null
}
invalidate()
}
}
ImageViewer(state = state)
return@Scaffold
}
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
@@ -144,6 +144,20 @@ internal class PermissionStep : OnboardingStep {
}
}
@Composable
private fun SectionHeader(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = modifier
.padding(horizontal = 16.dp)
.secondaryItemAlpha(),
)
}
@Composable
private fun PermissionCheckbox(
title: String,
@@ -6,6 +6,8 @@ import android.content.Intent
import android.provider.Settings
import android.webkit.WebStorage
import android.webkit.WebView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
import eu.kanade.tachiyomi.ui.more.OnboardingScreen
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
import eu.kanade.tachiyomi.util.system.powerManager
@@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext
import logcat.LogPriority
import mihon.app.di.appGraph
import okhttp3.Headers
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.i18n.MR
@@ -342,12 +346,43 @@ object SettingsAdvancedScreen : SearchableSettings {
basePreferences: BasePreferences,
): Preference.PreferenceGroup {
val context = LocalContext.current
val chooseColorProfile = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
uri?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
basePreferences.displayProfile.set(uri.toString())
}
}
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reader),
preferenceItems = listOf(
Preference.PreferenceItem.SwitchPreference(
preference = basePreferences.highQualityRenderer,
title = stringResource(MR.strings.pref_high_quality_renderer),
Preference.PreferenceItem.ListPreference(
preference = basePreferences.hardwareBitmapThreshold,
entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
.mapIndexed { index, option ->
val display = if (index == 0) {
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
} else {
option.toString()
}
option to display
}
.toMap(),
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
subtitleProvider = { value, options ->
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
},
visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_display_profile),
subtitle = basePreferences.displayProfile.get(),
onClick = {
chooseColorProfile.launch(arrayOf("*/*"))
},
),
),
)
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols
@@ -286,7 +287,7 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { context.appGraph.chapterCache }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize by produceState("", cacheReadableSizeSema) {
value = chapterCache.readableSize()
value = withContext(Dispatchers.IO) { chapterCache.readableSize }
}
return Preference.PreferenceGroup(
@@ -310,7 +311,7 @@ object SettingsDataScreen : SearchableSettings {
onClick = {
scope.launch {
try {
val deletedFiles = chapterCache.clear()
val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
cacheReadableSizeSema++
} catch (e: Exception) {
@@ -25,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -43,32 +42,27 @@ internal fun BasePreferenceWidget(
icon: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
widget: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
) {
val contentAlpha = if (enabled) 1f else DisabledAlpha
val highlighted = LocalPreferenceHighlighted.current
val minHeight = LocalPreferenceMinHeight.current
Row(
modifier = modifier
.highlightBackground(highlighted)
.sizeIn(minHeight = minHeight)
.clickable(enabled = enabled && onClick != null, onClick = { onClick?.invoke() })
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Box(
modifier = Modifier
.padding(start = PrefsHorizontalPadding, end = 8.dp)
.alpha(contentAlpha),
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
content = { icon() },
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = PrefsVerticalPadding)
.alpha(contentAlpha),
.padding(vertical = PrefsVerticalPadding),
) {
if (!title.isNullOrBlank()) {
Text(
@@ -129,4 +123,3 @@ internal val TrailingWidgetBuffer = 16.dp
internal val PrefsHorizontalPadding = 16.dp
internal val PrefsVerticalPadding = 16.dp
internal val TitleFontSize = 16.sp
internal val DisabledAlpha = 0.38f
@@ -1,35 +0,0 @@
package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun CheckboxPreferenceWidget(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
modifier = modifier,
title = title,
subtitle = subtitle,
icon = icon,
widget = {
Checkbox(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
enabled = enabled,
onPreferenceClick = { onCheckedChanged(!checked) },
)
}
@@ -1,7 +1,6 @@
package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -47,32 +46,11 @@ fun <T> ListPreferenceWidget(
)
if (isDialogShown) {
ListPreferenceDialog(
title = title,
value = value,
entries = entries,
onValueChange = onValueChange,
AlertDialog(
onDismissRequest = { isDialogShown = false },
)
}
}
@Composable
fun <T> ListPreferenceDialog(
title: String?,
value: T,
entries: Map<out T, String>,
onValueChange: (T) -> Unit,
onDismissRequest: () -> Unit,
message: String? = null,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = title?.let { { Text(text = it) } },
text = {
Column {
if (message != null) PreferenceDialogMessage(message)
Box(modifier = Modifier.weight(1f, fill = false)) {
title = { Text(text = title) },
text = {
Box {
val state = rememberLazyListState()
ScrollbarLazyColumn(state = state) {
entries.forEach { current ->
@@ -82,8 +60,8 @@ fun <T> ListPreferenceDialog(
label = current.value,
isSelected = isSelected,
onSelected = {
onValueChange(current.key)
onDismissRequest()
onValueChange(current.key!!)
isDialogShown = false
},
)
}
@@ -92,19 +70,14 @@ fun <T> ListPreferenceDialog(
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
}
},
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
@Composable
internal fun PreferenceDialogMessage(message: String) {
Text(text = message, modifier = Modifier.padding(bottom = 8.dp))
},
confirmButton = {
TextButton(onClick = { isDialogShown = false }) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
}
@Composable
@@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@@ -11,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.window.DialogProperties
import tachiyomi.i18n.MR
@@ -37,37 +35,16 @@ fun <T> MultiSelectListPreferenceWidget(
)
if (isDialogShown) {
MultiSelectListPreferenceDialog(
title = title,
values = values,
entries = entries,
onValuesChange = onValuesChange,
val selected = remember {
entries.keys
.filter { values.contains(it) }
.toMutableStateList()
}
AlertDialog(
onDismissRequest = { isDialogShown = false },
)
}
}
@Composable
fun <T> MultiSelectListPreferenceDialog(
title: String?,
values: Set<T>,
entries: Map<out T, String>,
onValuesChange: (Set<T>) -> Unit,
onDismissRequest: () -> Unit,
message: String? = null,
) {
val selected = remember {
entries.keys
.filter { values.contains(it) }
.toMutableStateList()
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = title?.let { { Text(text = it) } },
text = {
Column {
if (message != null) PreferenceDialogMessage(message)
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
title = { Text(text = title) },
text = {
LazyColumn {
entries.forEach { current ->
item {
val isSelected = selected.contains(current.key)
@@ -85,26 +62,25 @@ fun <T> MultiSelectListPreferenceDialog(
}
}
}
}
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
onClick = {
// Values without an entry stay, as they can't be unselected here
onValuesChange(values.filterNot { it in entries.keys }.toMutableSet().apply { addAll(selected) })
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
onClick = {
onValuesChange(selected.toMutableSet())
isDialogShown = false
},
) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = { isDialogShown = false }) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
}
@@ -19,7 +19,6 @@ fun SwitchPreferenceWidget(
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
@@ -31,11 +30,9 @@ fun SwitchPreferenceWidget(
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
enabled = enabled,
onPreferenceClick = { onCheckedChanged(!checked) },
)
}
@@ -24,7 +24,6 @@ fun TextPreferenceWidget(
icon: ImageVector? = null,
iconTint: Color = MaterialTheme.colorScheme.primary,
widget: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
onPreferenceClick: (() -> Unit)? = null,
) {
BasePreferenceWidget(
@@ -57,7 +56,6 @@ fun TextPreferenceWidget(
},
onClick = onPreferenceClick,
widget = widget,
enabled = enabled,
)
}
@@ -39,7 +39,7 @@ fun OrientationSelectDialog(
DialogContent(
orientation = orientation,
onChangeOrientation = {
viewModel.setOrientation(it)
viewModel.onChangeOrientation(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -1,65 +0,0 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
/**
* A page that failed to load or decode: the message, Retry, and Open in WebView when [onOpenInWebView] is given.
*
* [onButtonPressChange] tells a viewer that reads every touch for its own taps when a button is held.
*/
@Composable
fun ReaderError(
message: String?,
onRetry: () -> Unit,
onOpenInWebView: (() -> Unit)?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val buttonModifier = if (onButtonPressChange != null) Modifier.reportPress(onButtonPressChange) else Modifier
Column(
modifier = modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = message ?: stringResource(MR.strings.decode_image_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_retry))
}
if (onOpenInWebView != null) {
Button(onClick = onOpenInWebView, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_open_in_web_view))
}
}
}
}
internal fun Modifier.reportPress(onPressChange: (Boolean) -> Unit) = pointerInput(onPressChange) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
onPressChange(true)
waitForUpOrCancellation(pass = PointerEventPass.Initial)
onPressChange(false)
}
}
@@ -1,93 +0,0 @@
package eu.kanade.presentation.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.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderNavigationOverlay(
state: ReaderNavigationOverlayState,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = state.visible,
modifier = modifier,
enter = fadeIn(tween(FADE_DURATION)),
exit = fadeOut(tween(FADE_DURATION)),
) {
val regions = state.regions
val labels = regions.map { stringResource(it.type.nameRes) }
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val textStyle = remember(density) {
TextStyle(color = Color.White, fontSize = with(density) { 64f.toSp() })
}
val borderStyle = remember(textStyle) {
textStyle.copy(color = Color.Black, drawStyle = Stroke(width = 8f))
}
Canvas(modifier = Modifier.fillMaxSize()) {
regions.forEachIndexed { index, region ->
val rect = region.rect
val topLeft = Offset(size.width * rect.left, size.height * rect.top)
val regionSize = Size(size.width * rect.width, size.height * rect.height)
drawRect(color = region.type.color, topLeft = topLeft, size = regionSize)
val text = textMeasurer.measure(labels[index], textStyle)
val textTopLeft = Offset(
x = topLeft.x + (regionSize.width - text.size.width) / 2,
y = topLeft.y + (regionSize.height - text.size.height) / 2,
)
drawText(textMeasurer, labels[index], textTopLeft, borderStyle)
drawText(text, topLeft = textTopLeft)
}
}
}
}
@Stable
class ReaderNavigationOverlayState {
internal var regions by mutableStateOf(emptyList<ViewerNavigation.Region>())
internal var visible by mutableStateOf(false)
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
regions = navigation.getRegions()
if (visible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
visible = true
}
fun hide() {
visible = false
}
}
private const val FADE_DURATION = 1000
@@ -39,7 +39,7 @@ fun ReadingModeSelectDialog(
DialogContent(
readingMode = readingMode,
onChangeReadingMode = {
viewModel.setReadingMode(it)
viewModel.onChangeReadingMode(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -8,13 +8,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
@@ -26,20 +28,15 @@ import java.text.NumberFormat
@Composable
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
val isWebGpu by viewModel.isWebGpu.collectAsState()
if (isWebGpu) {
WebGpuViewerSettings(viewModel)
return
}
HeadingItem(MR.strings.pref_category_for_this_series)
val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) {
ReadingMode.entries.map {
FilterChip(
selected = it == readingMode,
onClick = { viewModel.setReadingMode(it) },
onClick = { viewModel.onChangeReadingMode(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
@@ -50,14 +47,14 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
ReaderOrientation.entries.map {
FilterChip(
selected = it == orientation,
onClick = { viewModel.setOrientation(it) },
onClick = { viewModel.onChangeOrientation(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
val resolvedReadingMode by viewModel.readingMode.collectAsState()
if (resolvedReadingMode.type is ReadingMode.ViewerType.Webtoon) {
val viewer by viewModel.viewerFlow.collectAsState()
if (viewer is WebtoonViewer) {
WebtoonViewerSettings(viewModel)
} else {
PagerViewerSettings(viewModel)
@@ -210,7 +207,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel
}
@Composable
internal fun TapZonesItems(
private fun ColumnScope.TapZonesItems(
selected: Int,
onSelect: (Int) -> Unit,
invertMode: ReaderPreferences.TappingInvertMode,
@@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
@@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
.onEach(TelemetryConfig::setCrashlyticsEnabled)
.launchIn(scope)
basePreferences.hardwareBitmapThreshold.let { preference ->
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
}
basePreferences.hardwareBitmapThreshold.changes()
.onEach { ImageUtil.hardwareBitmapThreshold = it }
.launchIn(scope)
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
// Updates widget update
@@ -0,0 +1,221 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import android.text.format.Formatter
import com.jakewharton.disklrucache.DiskLruCache
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response
import okio.buffer
import okio.sink
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import java.io.File
import java.io.IOException
/**
* Class used to create chapter cache
* For each image in a chapter a file is created
* For each chapter a Json list is created and converted to a file.
* The files are in format *md5key*.0
*
* @param context the application context.
*/
@Inject
@SingleIn(AppScope::class)
class ChapterCache(
private val context: Context,
private val json: Json,
) {
/** Cache class used for cache management. */
private val diskCache by lazy {
DiskLruCache.open(
File(context.cacheDir, "chapter_disk_cache"),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE,
)
}
/**
* Returns directory of cache.
*/
private val cacheDir: File
get() = diskCache.directory
/**
* Returns real size of directory.
*/
private val realSize: Long
get() = DiskUtil.getDirectorySize(cacheDir)
/**
* Returns real size of directory in human readable format.
*/
val readableSize: String
get() = Formatter.formatFileSize(context, realSize)
/**
* Get page list from cache.
*
* @param chapter the chapter.
* @return the list of pages.
*/
fun getPageListFromCache(chapter: Chapter): List<Page> {
// Get the key for the chapter.
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
// Convert JSON string to list of objects. Throws an exception if snapshot is null
return diskCache.get(key).use {
json.decodeFromString(it.getString(0))
}
}
/**
* Add page list to disk cache.
*
* @param chapter the chapter.
* @param pages list of pages.
*/
fun putPageListToCache(chapter: Chapter, pages: List<Page>) {
// Convert list of pages to json string.
val cachedValue = json.encodeToString(pages)
// Initialize the editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
try {
// Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
editor = diskCache.edit(key) ?: return
// Write chapter urls to cache.
editor.newOutputStream(0).sink().buffer().use {
it.write(cachedValue.toByteArray())
it.flush()
}
diskCache.flush()
editor.commit()
editor.abortUnlessCommitted()
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
// Ignore.
} finally {
editor?.abortUnlessCommitted()
}
}
/**
* Returns true if image is in cache.
*
* @param imageUrl url of image.
* @return true if in cache otherwise false.
*/
fun isImageInCache(imageUrl: String): Boolean {
return try {
val key = DiskUtil.hashKeyForDisk(imageUrl)
val inJournal = diskCache.get(key).use { it != null }
val fileExists = getImageFile(imageUrl).exists()
if (inJournal && !fileExists) {
logcat(LogPriority.WARN) { "Image is in journal but file is missing: $imageUrl" }
}
inJournal && fileExists
} catch (_: IOException) {
false
}
}
/**
* Get image file from url.
*
* @param imageUrl url of image.
* @return path of image.
*/
fun getImageFile(imageUrl: String): File {
// Get file from md5 key.
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName)
}
/**
* Add image to cache.
*
* @param imageUrl url of image.
* @param response http response from page.
* @throws IOException image error.
*/
@Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response) {
// Initialize editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
try {
// Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(imageUrl)
editor = diskCache.edit(key) ?: return
// Get OutputStream and write image with Okio.
response.body.source().saveTo(editor.newOutputStream(0))
diskCache.flush()
editor.commit()
} finally {
response.body.close()
editor?.abortUnlessCommitted()
}
}
fun clear(): Int {
var deletedFiles = 0
cacheDir.listFiles()?.forEach {
if (removeFileFromCache(it.name)) {
deletedFiles++
}
}
return deletedFiles
}
/**
* Remove file from cache.
*
* @param file name of file "md5.0".
* @return status of deletion for the file.
*/
private fun removeFileFromCache(file: String): Boolean {
// Make sure we don't delete the journal file (keeps track of cache)
if (file == "journal" || file.startsWith("journal.")) {
return false
}
return try {
// Remove the extension from the file to get the key of the cache
val key = file.substringBeforeLast(".")
// Remove file from cache
diskCache.remove(key)
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
false
}
}
private fun getKey(chapter: Chapter): String {
return "${chapter.mangaId}${chapter.url}"
}
}
/** Application cache version. */
private const val PARAMETER_APP_VERSION = 1
/** The number of values per cache entry. Must be positive. */
private const val PARAMETER_VALUE_COUNT = 1
/** The maximum number of bytes this cache should use to store. */
private const val PARAMETER_CACHE_SIZE = 100L * 1024 * 1024
@@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.core.graphics.createBitmap
import ca.mpreg.imagedecoder.ImageDecoder
import coil3.Canvas
import coil3.Image
import coil3.ImageLoader
@@ -14,39 +12,20 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.bitmapConfig
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder
import kotlin.math.min
import kotlin.math.sqrt
/**
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
*/
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
/**
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
*/
class DecodeResultImage(
val frame: ImageDecoder.Frame,
val isHdr: Boolean,
val hdrHeadroom: Float,
val gainmap: ImageDecoder.Gainmap?,
) : Image {
val image: java.nio.ByteBuffer get() = frame.image
// Taken now: a caller may close the frame once it has the pixels.
override val size: Long = frame.image.capacity().toLong()
override val width: Int get() = frame.width
override val height: Int get() = frame.height
override val shareable: Boolean get() = true
override fun draw(canvas: Canvas) {}
}
/**
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
@@ -63,105 +42,102 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override suspend fun decode(): DecodeResult {
if (options.webtoonStrips) return decodeStrips()
return resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec ->
// Raw frame for custom rendering; sampling is the caller's.
if (options.newDecoder) {
return@use DecodeResult(
image = DecodeResultImage(
dec.decodeNext(),
dec.isHdr,
dec.hdrHeadroom,
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
),
isSampled = false,
)
}
val decoder = resources.sourceOrNull()?.use {
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
}
// Shrunk while decoding. Sizes round up, so may be a pixel over BitmapFactory's.
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = dec.width,
srcHeight = dec.height,
dstWidth = options.size.widthPx(options.scale) { dec.width },
dstHeight = options.size.heightPx(options.scale) { dec.height },
scale = options.scale,
)
DecodeResult(
image = dec.decodeToBitmap(null, sampleSize).asImage(),
isSampled = sampleSize > 1,
)
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val srcWidth = decoder.width
val srcHeight = decoder.height
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
var bitmap = decoder.decode(sampleSize = sampleSize)
decoder.recycle()
check(bitmap != null) { "Failed to decode image" }
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
bitmap.recycle()
bitmap = hwBitmap
}
}
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,
)
}
/**
* Copies bands of rows straight from the native frame into strips, so the page never exists as
* one Java bitmap on top of the frame. The frame is decoded once, at the largest whole fraction
* still at least the strip width: per-strip regions would re-decode the rows above each.
* Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied.
*
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
* in still shows the page's detail, unless that would make the page take more than
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
*/
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
val (frame, isHdr, widths) = resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec ->
val bytesPerPixel = if (dec.isHdr) 8 else 4
val viewWidth = options.size.widthPx(options.scale) { dec.width }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = dec.width,
srcHeight = dec.height,
dstWidth = viewWidth,
dstHeight = options.size.heightPx(options.scale) { dec.height },
scale = options.scale,
)
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * dec.width / dec.height).toInt()
val stripWidth = maxOf(viewWidth, min(dec.width / sampleSize, budgetWidth))
// Older GPUs can't draw a texture wider than this
.coerceAtMost(MAX_STRIP_WIDTH)
.coerceIn(1, dec.width)
// ceil(width / decodeSample) >= stripWidth: strips only scale down
val decodeSample = (dec.width / stripWidth).coerceAtLeast(1)
Triple(dec.decode(null, decodeSample), dec.isHdr, stripWidth to dec.width)
}
val decoder = resources.sourceOrNull()?.use {
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
}
val (stripWidth, pageWidth) = widths
frame.use {
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
val bytesPerPixel = if (isHdr) 8 else 4
val rowBytes = frame.width * bytesPerPixel
val scale = stripWidth.toFloat() / frame.width
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
// Strips of about the same size in bytes, so each takes about as long to upload
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
val matrix = Matrix().apply { setScale(scale, scale) }
val band = createBitmap(frame.width, bandRows, config)
val strips = (0..<frame.height step bandRows).map { top ->
val rows = min(bandRows, frame.height - top)
if (rows != band.height) band.reconfigure(frame.width, rows, config)
band.copyPixelsFromBuffer(
frame.image.duplicate().apply {
position(top * rowBytes)
limit((top + rows) * rowBytes)
},
)
val strip = if (stripWidth < frame.width) {
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
} else {
band
}
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
val srcWidth = decoder.width
val viewWidth = options.size.widthPx(options.scale) { srcWidth }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = decoder.width,
srcHeight = decoder.height,
dstWidth = viewWidth,
dstHeight = options.size.heightPx(options.scale) { decoder.height },
scale = options.scale,
)
val page = decoder.decode(sampleSize = sampleSize)
decoder.recycle()
check(page != null) { "Failed to decode image" }
val config = page.config ?: Bitmap.Config.ARGB_8888
val bytesPerPixel = page.rowBytes / page.width
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt()
val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth))
// Older GPUs can't draw a texture wider than this
.coerceAtMost(MAX_STRIP_WIDTH)
.coerceIn(1, page.width)
val scale = stripWidth.toFloat() / page.width
// Strips of about the same size in bytes, so each takes about as long to upload
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height)
val matrix = Matrix().apply { setScale(scale, scale) }
val strips = (0..<page.height step bandRows).map { top ->
val rows = min(bandRows, page.height - top)
val band = if (stripWidth < page.width) {
Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true)
} else {
Bitmap.createBitmap(page, 0, top, page.width, rows)
}
band.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth)
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
band.copy(config, false).also { if (band !== page) band.recycle() }
}
page.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth)
}
class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
return if (options.webtoonStrips || isApplicable(result.source.source())) {
ImageDecoder(result.source, options)
} else {
null
@@ -173,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
ImageUtil.findImageType(it)
}
return when (type) {
ImageUtil.ImageType.AVIF,
ImageUtil.ImageType.JXL,
ImageUtil.ImageType.HEIF,
ImageUtil.ImageType.JP2,
-> true
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
else -> false
}
}
@@ -187,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override fun hashCode() = javaClass.hashCode()
}
companion object {
var displayProfile: ByteArray? = null
}
}
private const val STRIP_BYTES = 4 * 1024 * 1024
@@ -195,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
private const val MAX_STRIP_WIDTH = 4096
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
// Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel
private val stripDecodes = Semaphore(1)
@@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean
get() = getExtra(webtoonStripsKey)
private val webtoonStripsKey = Extras.Key(default = false)
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
extras[newDecoderKey] = enable
}
val Options.newDecoder: Boolean
get() = getExtra(newDecoderKey)
private val newDecoderKey = Extras.Key(default = false)
@@ -7,6 +7,7 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.domain.manga.model.getComicInfo
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler
@@ -49,7 +50,6 @@ import kotlinx.coroutines.withContext
import logcat.LogPriority
import mihon.core.archive.ZipWriter
import mihon.core.metro.AppCoroutineScope
import mihon.data.cache.ChapterCache
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
@@ -64,6 +64,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds
@@ -543,8 +544,10 @@ class Downloader(
// If the image is already downloaded, do nothing. Otherwise download from network
val file = when {
imageFile != null -> imageFile
else -> copyImageFromCache(page.imageUrl!!, tmpDir, filename)
?: downloadImage(page, download.source, tmpDir, filename)
chapterCache.isImageInCache(
page.imageUrl!!,
) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename)
else -> downloadImage(page, download.source, tmpDir, filename)
}
// When the page is ready, set page path, progress (just in case) and status
@@ -607,25 +610,24 @@ class Downloader(
}
/**
* Copies the image from cache to file in tmpDir, or returns null when it isn't cached.
* Copies the image from cache to file in tmpDir.
*
* @param imageUrl the url of the image.
* @param cacheFile the file from cache.
* @param tmpDir the temporary directory of the download.
* @param filename the filename of the image.
*/
private suspend fun copyImageFromCache(imageUrl: String, tmpDir: UniFile, filename: String): UniFile? {
val tmpFile = chapterCache.openImage(imageUrl)?.use { input ->
// Delete temp file if it exists
tmpDir.findFile("$filename.tmp")?.delete()
tmpDir.createFile("$filename.tmp")!!.also { tmpFile ->
tmpFile.openOutputStream().use { output ->
input.copyTo(output)
}
private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
// Delete temp file if it exists
tmpDir.findFile("$filename.tmp")?.delete()
val tmpFile = tmpDir.createFile("$filename.tmp")!!
cacheFile.inputStream().use { input ->
tmpFile.openOutputStream().use { output ->
input.copyTo(output)
}
} ?: return null
chapterCache.removeImage(imageUrl)
val extension = tmpFile.openInputStream().use { ImageUtil.findImageType(it) } ?: return tmpFile
}
val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile
tmpFile.renameTo("$filename.${extension.extension}")
cacheFile.delete()
return tmpFile
}
@@ -50,6 +50,20 @@ object NotificationHandler {
)
}
/**
* Returns [PendingIntent] that prompts user with apk install intent
*
* @param context context
* @param uri uri of apk that is installed
*/
fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, ExtensionInstaller.APK_MIME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
fun openUrl(context: Context, url: String): PendingIntent {
val notificationIntent = Intent(Intent.ACTION_VIEW, url.toUri())
return PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
@@ -294,6 +294,26 @@ class NotificationReceiver : BroadcastReceiver() {
)
}
/**
* Returns [PendingIntent] that starts a service which dismissed the notification
*
* @param context context of application
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_DISMISS_NOTIFICATION
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/**
* Returns [PendingIntent] that starts a service which dismissed the notification
*
@@ -1,73 +1,49 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import androidx.appcompat.view.ContextThemeWrapper
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.widget.doAfterTextChanged
import androidx.preference.CheckBoxPreference
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.preference.DialogPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference
import androidx.preference.R
import androidx.preference.forEach
import androidx.preference.getOnBindEditTextListener
import androidx.preference.setOnStateChangeListener
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.widget.CheckboxPreferenceWidget
import eu.kanade.presentation.more.settings.widget.ListPreferenceDialog
import eu.kanade.presentation.more.settings.widget.MultiSelectListPreferenceDialog
import eu.kanade.presentation.more.settings.widget.PreferenceDialogMessage
import eu.kanade.presentation.more.settings.widget.PreferenceGroupHeader
import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.util.view.setIncognito
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mihon.app.di.appGraph
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
class SourcePreferencesScreen(val sourceId: Long) : Screen() {
@@ -77,290 +53,135 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
// The preferences are made anew after a configuration change, so an open dialog is restored by key
var savedDialogKey by rememberSaveable { mutableStateOf<String?>(null) }
val screen by produceState<ExtensionPreferenceScreen?>(initialValue = null) {
val source = context.appGraph.sourceManager.getOrStub(sourceId)
// Reading a value loads the file, which shouldn't happen on the main thread
val sourcePreferences = (source as? ConfigurableSource)?.let {
withContext(Dispatchers.IO) { it.sourcePreferences().apply { all } }
}
value = ExtensionPreferenceScreen(context, source, sourcePreferences, savedDialogKey)
val source by produceState<Source?>(initialValue = null) {
value = context.appGraph.sourceManager.getOrStub(sourceId)
}
val preferenceScreen = screen
if (preferenceScreen == null) {
if (source == null) {
LoadingScreen()
return
}
DisposableEffect(preferenceScreen) {
onDispose { preferenceScreen.dispose() }
}
SideEffect { savedDialogKey = preferenceScreen.dialogPreference?.key }
Scaffold(
topBar = {
AppBar(
title = preferenceScreen.title,
title = source.toString(),
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
val items = remember(preferenceScreen.version) { preferenceScreen.items() }
ScrollbarLazyColumn(contentPadding = contentPadding) {
items(items) { PreferenceRow(it) }
FragmentContainer(
fragmentManager = (context as FragmentActivity).supportFragmentManager,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
) {
add(it, SourcePreferencesFragment.getInstance(sourceId), null)
}
}
}
preferenceScreen.dialogPreference?.let {
PreferenceDialog(
preference = it,
onDismissRequest = { preferenceScreen.dialogPreference = null },
)
/**
* From https://stackoverflow.com/questions/60520145/fragment-container-in-jetpack-compose/70817794#70817794
*/
@Composable
private fun FragmentContainer(
fragmentManager: FragmentManager,
modifier: Modifier = Modifier,
commit: FragmentTransaction.(containerId: Int) -> Unit,
) {
val containerId by rememberSaveable {
mutableIntStateOf(View.generateViewId())
}
}
}
/**
* The screen an extension fills in [ConfigurableSource.setupPreferenceScreen]. The [androidx.preference] objects stay
* the model, so extensions keep the exact behaviour of the library, and are only drawn with Compose.
*/
@Stable
@SuppressLint("RestrictedApi")
private class ExtensionPreferenceScreen(
context: Context,
source: Source,
sourcePreferences: SharedPreferences?,
dialogKey: String?,
) {
val title = source.toString()
// Bumped on every change the library would rebind a preference's view for
var version by mutableIntStateOf(0)
private set
var dialogPreference by mutableStateOf<DialogPreference?>(null)
private val screen: PreferenceScreen
init {
val preferenceManager = PreferenceManager(context)
if (sourcePreferences != null) {
preferenceManager.preferenceDataStore = SharedPreferencesDataStore(sourcePreferences)
}
preferenceManager.setOnDisplayPreferenceDialogListener { dialogPreference = it as? DialogPreference }
screen = preferenceManager.createPreferenceScreen(context)
(source as? ConfigurableSource)?.setupPreferenceScreen(screen)
preferenceManager.setPreferences(screen)
screen.onAttached()
observe(screen)
dialogPreference = dialogKey?.let { screen.findPreference<Preference>(it) as? DialogPreference }
}
private fun observe(preference: Preference) {
preference.setOnStateChangeListener {
// A group changing may mean preferences were added to it
if (it is PreferenceGroup) observe(it)
version++
}
if (preference is PreferenceGroup) {
repeat(preference.preferenceCount) { observe(preference.getPreference(it)) }
}
}
fun items(): List<PreferenceItem> = buildList { addVisible(screen) }
private fun MutableList<PreferenceItem>.addVisible(group: PreferenceGroup) {
repeat(group.preferenceCount) {
val preference = group.getPreference(it)
if (!preference.isVisible) return@repeat
add(PreferenceItem.of(preference))
if (preference is PreferenceGroup) addVisible(preference)
}
}
fun dispose() {
screen.onDetached()
}
}
/**
* What a preference shows, read once per change so the list redraws when an extension changes a preference.
*/
private data class PreferenceItem(
val preference: Preference,
val title: String?,
val summary: String?,
val enabled: Boolean,
val checked: Boolean?,
) {
companion object {
fun of(preference: Preference): PreferenceItem {
val summary = when {
preference !is TwoStatePreference -> preference.summary
preference.isChecked && !preference.summaryOn.isNullOrEmpty() -> preference.summaryOn
!preference.isChecked && !preference.summaryOff.isNullOrEmpty() -> preference.summaryOff
else -> preference.summary
}
return PreferenceItem(
preference = preference,
title = preference.title?.toString(),
summary = summary?.toString(),
enabled = preference.isEnabled,
checked = (preference as? TwoStatePreference)?.isChecked,
)
}
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun PreferenceRow(item: PreferenceItem) {
val preference = item.preference
val onClick = if (preference.isSelectable) preference::performClick else null
when {
preference is PreferenceGroup -> PreferenceGroupHeader(title = item.title.orEmpty())
preference is CheckBoxPreference -> CheckboxPreferenceWidget(
title = item.title.orEmpty(),
subtitle = item.summary,
checked = item.checked == true,
enabled = item.enabled,
onCheckedChanged = { onClick?.invoke() },
)
item.checked != null -> SwitchPreferenceWidget(
title = item.title.orEmpty(),
subtitle = item.summary,
checked = item.checked,
enabled = item.enabled,
onCheckedChanged = { onClick?.invoke() },
)
else -> TextPreferenceWidget(
title = item.title,
subtitle = item.summary,
enabled = item.enabled,
onPreferenceClick = onClick,
)
}
}
@Composable
private fun PreferenceDialog(preference: DialogPreference, onDismissRequest: () -> Unit) {
val title = (preference.dialogTitle?.takeIf { it.isNotEmpty() } ?: preference.title)?.toString()
val message = preference.dialogMessage?.toString()
when (preference) {
is ListPreference -> ListPreferenceDialog(
title = title,
message = message,
value = preference.value,
entries = entryMap(preference.entries, preference.entryValues),
onValueChange = {
if (preference.callChangeListener(it)) preference.value = it
var initialized by rememberSaveable { mutableStateOf(false) }
AndroidView(
modifier = modifier,
factory = { context ->
FragmentContainerView(context)
.apply { id = containerId }
},
onDismissRequest = onDismissRequest,
)
is MultiSelectListPreference -> MultiSelectListPreferenceDialog(
title = title,
message = message,
values = preference.values,
entries = entryMap(preference.entries, preference.entryValues),
onValuesChange = {
val values = it.toHashSet()
if (values != preference.values && preference.callChangeListener(values)) {
preference.values = values
update = { view ->
if (!initialized) {
fragmentManager.commit { commit(view.id) }
initialized = true
} else {
fragmentManager.onContainerAvailable(view)
}
},
onDismissRequest = onDismissRequest,
)
is EditTextPreference -> EditTextPreferenceDialog(preference, title, message, onDismissRequest)
else -> LaunchedEffect(preference) { onDismissRequest() }
}
/** Access to package-private method in FragmentManager through reflection */
private fun FragmentManager.onContainerAvailable(view: FragmentContainerView) {
val method = FragmentManager::class.java.getDeclaredMethod(
"onContainerAvailable",
FragmentContainerView::class.java,
)
method.isAccessible = true
method.invoke(this, view)
}
}
private fun entryMap(entries: Array<CharSequence>?, entryValues: Array<CharSequence>?): Map<String, String> {
return entryValues.orEmpty().zip(entries.orEmpty()) { value, entry -> value.toString() to entry.toString() }.toMap()
}
class SourcePreferencesFragment : PreferenceFragmentCompat() {
/**
* Extensions get a real [EditText] through [EditTextPreference.OnBindEditTextListener], and some validate input by
* disabling the dialog's `android.R.id.button1` found from [EditText.getRootView], so one stands in for the OK button.
*/
@Composable
private fun EditTextPreferenceDialog(
preference: EditTextPreference,
title: String?,
message: String?,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// What's typed, kept across a configuration change
var draft by rememberSaveable { mutableStateOf<String?>(null) }
var editText by remember { mutableStateOf<EditText?>(null) }
var confirmEnabled by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = title?.let { { Text(text = it) } },
text = {
// Like the library's dialog, which shows the keyboard as it opens
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
SideEffect { window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) }
Column {
if (message != null) PreferenceDialogMessage(message)
AndroidView(
factory = { viewContext ->
LinearLayout(viewContext).apply {
val confirmButton = object : Button(viewContext) {
init {
id = android.R.id.button1
visibility = GONE
}
override fun getContext(): Context? {
val superCtx = super.getContext() ?: return null
val tv = TypedValue()
superCtx.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
return ContextThemeWrapper(superCtx, tv.resourceId)
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
confirmEnabled = enabled
}
}
addView(confirmButton)
val field = EditText(viewContext).apply {
// In the order the library's own dialog binds it
setText(draft ?: preference.text)
setSelection(text.length)
setIncognito(context.appGraph.basePreferences, scope)
preference.getOnBindEditTextListener()?.onBindEditText(this)
doAfterTextChanged { draft = it?.toString() }
// Focus only takes once attached to the dialog's window
post { requestFocus() }
}
addView(field, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText = field
}
},
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = confirmEnabled,
onClick = {
val value = editText?.text?.toString()
if (value != null && preference.callChangeListener(value)) {
preference.text = value
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
lifecycleScope.launch {
preferenceScreen = populateScreen()
}
}
private suspend fun populateScreen(): PreferenceScreen {
val sourceId = requireArguments().getLong(SOURCE_ID)
val appGraph = requireContext().appGraph
val source = appGraph.sourceManager.getOrStub(sourceId)
val basePreferences = appGraph.basePreferences
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
if (source is ConfigurableSource) {
val sourcePreferences = withContext(Dispatchers.IO) { source.sourcePreferences().also { it.all } }
val dataStore = SharedPreferencesDataStore(sourcePreferences)
preferenceManager.preferenceDataStore = dataStore
source.setupPreferenceScreen(sourceScreen)
sourceScreen.forEach { pref ->
pref.isIconSpaceReserved = false
pref.isSingleLineTitle = false
if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
pref.dialogTitle = pref.title
}
// Apply incognito IME for EditTextPreference
if (pref is EditTextPreference) {
val setListener = pref.getOnBindEditTextListener()
pref.setOnBindEditTextListener {
setListener?.onBindEditText(it)
it.setIncognito(basePreferences, lifecycleScope)
}
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_ok))
}
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
return sourceScreen
}
companion object {
private const val SOURCE_ID = "source_id"
fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply {
arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
}
},
)
}
}
}
@@ -80,6 +80,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.extension.ExtensionManager
@@ -98,6 +99,7 @@ import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
import eu.kanade.tachiyomi.util.system.updaterEnabled
import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -113,7 +115,6 @@ import mihon.app.di.AppGraph
import mihon.app.di.appGraph
import mihon.core.metro.metroGraph
import mihon.core.migration.Migrator
import mihon.data.cache.ChapterCache
import mihon.domain.database.repository.DatabaseRepository
import mihon.feature.support.SupportUsScreen
import mihon.icons.materialsymbols.MaterialSymbols
@@ -324,7 +325,9 @@ class MainActivity : BaseActivity() {
setSplashScreenExitAnimation(splashScreen)
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
lifecycleScope.launch { chapterCache.clear() }
lifecycleScope.launch(Dispatchers.IO) {
chapterCache.clear()
}
}
}
@@ -1,75 +1,56 @@
package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.View.LAYER_TYPE_HARDWARE
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.transition.doOnEnd
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile
import dev.zacsweers.metro.Inject
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.reader.DisplayRefreshHost
import eu.kanade.presentation.reader.OrientationSelectDialog
import eu.kanade.presentation.reader.ReaderContentOverlay
import eu.kanade.presentation.reader.ReaderNavigationOverlay
import eu.kanade.presentation.reader.ReaderNavigationOverlayState
import eu.kanade.presentation.reader.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReaderPageIndicator
import eu.kanade.presentation.reader.ReadingModeSelectDialog
@@ -77,8 +58,10 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.components.ChapterNavigatorType
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
@@ -92,10 +75,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.Action
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
@@ -103,13 +84,14 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.app.di.AppGraph
@@ -118,8 +100,9 @@ import tachiyomi.core.common.Constants
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
import java.io.ByteArrayOutputStream
import kotlin.time.Duration.Companion.seconds
class ReaderActivity : BaseActivity() {
@@ -139,26 +122,23 @@ class ReaderActivity : BaseActivity() {
@Inject private lateinit var preferences: BasePreferences
lateinit var binding: ReaderActivityBinding
val viewModel by viewModels<ReaderViewModel> { graph.viewModelFactory }
val settingsViewModel by viewModels<ReaderSettingsViewModel> {
viewModelFactory { initializer { ReaderSettingsViewModel(viewModel, readerPreferences, preferences) } }
}
private var assistUrl: String? = null
/**
* Configuration at reader level, like background color or forced orientation.
*/
private var config: ReaderConfig? = null
private var menuToggleToast: Toast? = null
private var readingModeToast: Toast? = null
private val displayRefreshHost = DisplayRefreshHost()
private val navigationOverlayState = ReaderNavigationOverlayState()
private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, window.decorView) }
private var isLoadingViewer by mutableStateOf(false)
/**
* Viewer used to display the pages (pager, webtoon, ...).
*/
private val viewerFlow = MutableStateFlow<Viewer?>(null)
private val viewer: Viewer? inline get() = viewerFlow.value
private var loadingIndicator: ReaderProgressIndicator? = null
var isScrollingThroughPages = false
private set
@@ -188,140 +168,9 @@ class ReaderActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setComposeContent {
val state by viewModel.state.collectAsState()
val general by settingsViewModel.general.collectAsState()
val showPageNumber by readerPreferences.showPageNumber.collectAsState()
val flashOnPageChange by readerPreferences.flashOnPageChange.collectAsState()
val colorOverlayEnabled by readerPreferences.colorFilter.collectAsState()
val colorOverlay by readerPreferences.colorFilterValue.collectAsState()
val colorOverlayMode by readerPreferences.colorFilterMode.collectAsState()
val brightness by settingsViewModel.brightness.collectAsState()
val navigation = settingsViewModel.navigation.collectAsState().value
val backgroundColor = remember(general.theme) { Color(baseContext.readerBackgroundColor(general.theme)) }
val colorOverlayBlendMode = remember(colorOverlayMode) {
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
}
LaunchedEffect(brightness) { setWindowBrightness(brightness) }
val forceNavigationOverlay = remember { settingsViewModel.takeNavigationOverlayForNewUser() }
LaunchedEffect(navigation) {
if (navigation == null) return@LaunchedEffect
val showOnStart = forceNavigationOverlay || settingsViewModel.general.value.navigationOverlayOnStart
navigationOverlayState.setNavigation(navigation, showOnStart)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.onPreviewKeyEvent(::onKeyEvent)
.focusRequester(focusRequester)
.focusable()
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
navigationOverlayState.hide()
}
}
.then(if (general.keepScreenOn) Modifier.keepScreenOn() else Modifier),
) {
ReaderViewer()
if (isLoadingViewer) {
LoadingScreen()
return@Box
}
ReaderContentOverlay(
brightness = brightness,
color = colorOverlay.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayBlendMode,
)
if (!state.menuVisible && showPageNumber) {
ReaderPageIndicator(
currentPage = state.currentPage,
totalPages = state.totalPages,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding(),
)
}
if (flashOnPageChange) {
DisplayRefreshHost(hostState = displayRefreshHost)
}
ReaderNavigationOverlay(state = navigationOverlayState)
AppBars(state = state)
}
if (state.isLoadingAdjacentChapter) {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text(stringResource(MR.strings.loading))
}
},
)
}
val onDismissRequest = viewModel::closeDialog
when (state.dialog) {
is ReaderViewModel.Dialog.Settings -> {
ReaderSettingsDialog(
onDismissRequest = onDismissRequest,
onShowMenus = { setMenuVisibility(true) },
onHideMenus = { setMenuVisibility(false) },
viewModel = settingsViewModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsViewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode.get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsViewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
)
}
null -> {}
}
}
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.setComposeOverlay()
if (!viewModel.hasValidArgs) {
finish()
@@ -334,6 +183,7 @@ class ReaderActivity : BaseActivity() {
Notifications.ID_NEW_CHAPTERS,
)
config = ReaderConfig()
setMenuVisibility(viewModel.state.value.menuVisible)
// Finish when incognito mode is disabled
@@ -349,6 +199,12 @@ class ReaderActivity : BaseActivity() {
.onEach(::setInitialChapterError)
.launchIn(lifecycleScope)
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
.onEach(::setProgressDialog)
.launchIn(lifecycleScope)
viewModel.state
.map { it.manga }
.distinctUntilChanged()
@@ -392,12 +248,100 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope)
}
private fun ReaderActivityBinding.setComposeOverlay(): Unit = composeOverlay.setComposeContent {
val state by viewModel.state.collectAsState()
val showPageNumber by readerPreferences.showPageNumber.collectAsState()
val settingsviewModel = remember {
ReaderSettingsViewModel(
readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
preferences = readerPreferences,
)
}
Box(modifier = Modifier.fillMaxSize()) {
if (!state.menuVisible && showPageNumber) {
ReaderPageIndicator(
currentPage = state.currentPage,
totalPages = state.totalPages,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding(),
)
}
ContentOverlay(state = state)
AppBars(state = state)
}
val onDismissRequest = viewModel::closeDialog
when (state.dialog) {
is ReaderViewModel.Dialog.Loading -> {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text(stringResource(MR.strings.loading))
}
},
)
}
is ReaderViewModel.Dialog.Settings -> {
ReaderSettingsDialog(
onDismissRequest = onDismissRequest,
onShowMenus = { setMenuVisibility(true) },
onHideMenus = { setMenuVisibility(false) },
viewModel = settingsviewModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsviewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode.get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsviewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
)
}
null -> {}
}
}
/**
* Called when the activity is destroyed. Cleans up the viewer and any view.
* Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
*/
override fun onDestroy() {
super.onDestroy()
viewer?.destroy()
viewModel.state.value.viewer?.destroy()
config = null
menuToggleToast?.cancel()
readingModeToast?.cancel()
}
@@ -452,97 +396,59 @@ class ReaderActivity : BaseActivity() {
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_N) {
loadNextChapter()
return true
} else if (keyCode == KeyEvent.KEYCODE_P) {
loadPreviousChapter()
return true
}
return super.onKeyUp(keyCode, event)
}
/**
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handled = viewModel.state.value.viewer?.handleKeyEvent(event) ?: false
return handled || super.dispatchKeyEvent(event)
}
/**
* Dispatches a generic motion event. If the viewer doesn't handle it, call the default
* implementation.
*/
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
val handled = viewModel.state.value.viewer?.handleGenericMotionEvent(event) ?: false
return handled || super.dispatchGenericMotionEvent(event)
}
@Composable
private fun ReaderViewer() {
val viewer by viewerFlow.collectAsState()
val general by settingsViewModel.general.collectAsState()
val insets = when {
!general.fullscreen -> WindowInsets.systemBars
!general.drawUnderCutout -> WindowInsets.displayCutout
else -> WindowInsets(0)
}
val colorFilter = remember(general.grayscale, general.invertedColors) {
viewerColorFilter(general.grayscale, general.invertedColors)
private fun ContentOverlay(state: ReaderViewModel.State) {
val flashOnPageChange by readerPreferences.flashOnPageChange.collectAsState()
val colorOverlayEnabled by readerPreferences.colorFilter.collectAsState()
val colorOverlay by readerPreferences.colorFilterValue.collectAsState()
val colorOverlayMode by readerPreferences.colorFilterMode.collectAsState()
val colorOverlayBlendMode = remember(colorOverlayMode) {
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
}
// Each viewer filters its page images alone: transition, loading and error pages keep their colours.
CompositionLocalProvider(LocalPageColorFilter provides colorFilter) {
key(viewer) {
viewer?.Content(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(insets),
)
}
}
}
ReaderContentOverlay(
brightness = state.brightnessOverlayValue,
color = colorOverlay.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayBlendMode,
)
/**
* Handles a key [event] before anything else in the reader. Movement happens on key up.
*/
private fun onKeyEvent(event: KeyEvent): Boolean {
val viewer = viewer ?: return false
val navigation = settingsViewModel.navigation.value ?: return false
val isUp = event.type == KeyEventType.KeyUp
val action = when (event.key) {
Key.VolumeDown, Key.VolumeUp -> {
val general = settingsViewModel.general.value
if (!general.volumeKeysEnabled || viewModel.state.value.menuVisible) return false
if ((event.key == Key.VolumeDown) != general.volumeKeysInverted) Action.FORWARD else Action.BACKWARD
}
Key.DirectionRight -> if (event.isCtrlPressed) {
Action.FORWARD
} else {
navigation.actionOf(
NavigationRegion.RIGHT,
)
}
Key.DirectionLeft -> if (event.isCtrlPressed) {
Action.BACKWARD
} else {
navigation.actionOf(
NavigationRegion.LEFT,
)
}
Key.DirectionDown, Key.PageDown -> Action.FORWARD
Key.DirectionUp, Key.PageUp -> Action.BACKWARD
Key.Menu -> Action.MENU
Key.N -> {
if (isUp) loadNextChapter()
return true
}
Key.P -> {
if (isUp) loadPreviousChapter()
return true
}
else -> return false
}
if (isUp) {
when (action) {
Action.MENU -> toggleMenu()
Action.FORWARD -> viewer.moveForward()
Action.BACKWARD -> viewer.moveBackward()
}
}
return true
}
/**
* Called from the viewer when it's tapped at [pos], given as a fraction of its size.
*/
fun onViewerTap(pos: Offset) {
val action = settingsViewModel.navigation.value?.getAction(pos) ?: Action.MENU
when (action) {
Action.MENU -> toggleMenu()
Action.FORWARD -> viewer?.moveForward()
Action.BACKWARD -> viewer?.moveBackward()
if (flashOnPageChange) {
DisplayRefreshHost(hostState = displayRefreshHost)
}
}
@Composable
fun AppBars(state: ReaderViewModel.State) {
val isHttpSource = state.source is HttpSource
val readingMode = ReadingMode.fromPreference(viewModel.getMangaReadingMode())
val cropBorderPaged by readerPreferences.cropBorders.collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState()
@@ -550,7 +456,9 @@ class ReaderActivity : BaseActivity() {
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState()
val verticalNavigator = verticalNavigatorModes.contains(readingMode)
val verticalNavigator = verticalNavigatorModes.contains(
ReadingMode.fromPreference(viewModel.getMangaReadingMode()),
)
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState()
@@ -568,7 +476,7 @@ class ReaderActivity : BaseActivity() {
onShare = ::shareChapter.takeIf { isHttpSource },
chapterNavigatorType = if (!verticalNavigator) {
if (readingMode == ReadingMode.RIGHT_TO_LEFT) {
if (state.viewer is R2LPagerViewer) {
ChapterNavigatorType.HORIZONTAL_RTL
} else {
ChapterNavigatorType.HORIZONTAL_LTR
@@ -629,22 +537,35 @@ class ReaderActivity : BaseActivity() {
* Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer.
*/
private fun updateViewer() {
val prevViewer = viewer
val readingMode = viewModel.getMangaReadingMode()
settingsViewModel.setViewerReadingMode(ReadingMode.fromPreference(readingMode))
val newViewer = ReadingMode.toViewer(readingMode, this)
val prevViewer = viewModel.state.value.viewer
val newViewer = ReadingMode.toViewer(viewModel.getMangaReadingMode(), this)
setOrientation(viewModel.getMangaOrientation())
if (window.sharedElementEnterTransition is MaterialContainerTransform) {
// Wait until transition is complete to avoid crash on API 26
window.sharedElementEnterTransition.doOnEnd {
setOrientation(viewModel.getMangaOrientation())
}
} else {
setOrientation(viewModel.getMangaOrientation())
}
// Destroy previous viewer if there was one
prevViewer?.destroy()
viewerFlow.value = newViewer
if (prevViewer != null) {
prevViewer.destroy()
binding.viewerContainer.removeAllViews()
}
viewModel.onViewerLoaded(newViewer)
updateViewerInset(readerPreferences.fullscreen.get(), readerPreferences.drawUnderCutout.get())
binding.viewerContainer.addView(newViewer.getView())
if (readerPreferences.showReadingMode.get()) {
showReadingModeToast(viewModel.getMangaReadingMode())
}
isLoadingViewer = true
loadingIndicator = ReaderProgressIndicator(this)
binding.readerContainer.addView(loadingIndicator)
startPostponedEnterTransition()
}
private fun openMangaScreen() {
@@ -695,9 +616,10 @@ class ReaderActivity : BaseActivity() {
* method to the current viewer, but also set the subtitle on the toolbar, and
* hides or disables the reader prev/next buttons if there's a prev or next chapter
*/
@SuppressLint("RestrictedApi")
private fun setChapters(viewerChapters: ViewerChapters) {
isLoadingViewer = false
viewer?.setChapters(viewerChapters)
binding.readerContainer.removeView(loadingIndicator)
viewModel.state.value.viewer?.setChapters(viewerChapters)
lifecycleScope.launch(Dispatchers.IO) {
viewModel.getChapterUrl()?.let { url ->
@@ -716,12 +638,26 @@ class ReaderActivity : BaseActivity() {
toast(error.message)
}
/**
* Called from the presenter whenever it's loading the next or previous chapter. It shows or
* dismisses a non-cancellable dialog to prevent user interaction according to the value of
* [show]. This is only used when the next/previous buttons on the toolbar are clicked; the
* other cases are handled with chapter transitions on the viewers and chapter preloading.
*/
private fun setProgressDialog(show: Boolean) {
if (show) {
viewModel.showLoadingDialog()
} else {
viewModel.closeDialog()
}
}
/**
* Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
* page is not found.
*/
private fun moveToPageIndex(index: Int) {
val viewer = this.viewer ?: return
val viewer = viewModel.state.value.viewer ?: return
val currentChapter = viewModel.state.value.currentChapter ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return
viewer.moveToPage(page)
@@ -860,41 +796,172 @@ class ReaderActivity : BaseActivity() {
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 the minimum brightness is used, and the overlay darkens further.
* From 1 to 100 it sets that value as brightness.
* 0 sets system brightness.
* Updates viewer inset depending on fullscreen reader preferences.
*/
private fun setWindowBrightness(value: Int) {
// Calculate and set reader brightness.
val readerBrightness = when {
value > 0 -> {
value / 100f
}
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
}
private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
if (!::binding.isInitialized) return
val view = binding.viewerContainer
private fun viewerColorFilter(grayscale: Boolean, invertedColors: Boolean): ColorFilter? {
if (!grayscale && !invertedColors) return null
val matrix = ColorMatrix()
if (invertedColors) {
matrix *= ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f,
),
)
view.applyInsetsPadding(ViewCompat.getRootWindowInsets(view), fullscreen, drawUnderCutout)
ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
view.applyInsetsPadding(windowInsets, fullscreen, drawUnderCutout)
windowInsets
}
}
if (grayscale) {
matrix *= ColorMatrix().apply { setToSaturation(0f) }
private fun View.applyInsetsPadding(
windowInsets: WindowInsetsCompat?,
fullscreen: Boolean,
drawUnderCutout: Boolean,
) {
val insets = when {
!fullscreen -> windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars())
!drawUnderCutout -> windowInsets?.getInsets(WindowInsetsCompat.Type.displayCutout())
else -> null
}
?: Insets.NONE
setPadding(insets.left, insets.top, insets.right, insets.bottom)
}
/**
* Class that handles the user preferences of the reader.
*/
private inner class ReaderConfig {
private fun getCombinedPaint(grayscale: Boolean, invertedColors: Boolean): Paint {
return Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix().apply {
if (grayscale) {
setSaturation(0f)
}
if (invertedColors) {
postConcat(
ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f,
),
),
)
}
},
)
}
}
/*
* Initializes the reader subscriptions.
*/
init {
readerPreferences.readerTheme.changes()
.onEach { theme ->
binding.readerContainer.setBackgroundColor(baseContext.readerBackgroundColor(theme))
}
.launchIn(lifecycleScope)
preferences.displayProfile.changes()
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn.changes()
.onEach(::setKeepScreenOn)
.launchIn(lifecycleScope)
readerPreferences.customBrightness.changes()
.onEach(::setCustomBrightness)
.launchIn(lifecycleScope)
combine(
readerPreferences.grayscale.changes(),
readerPreferences.invertedColors.changes(),
) { grayscale, invertedColors -> grayscale to invertedColors }
.onEach { (grayscale, invertedColors) ->
setLayerPaint(grayscale, invertedColors)
}
.launchIn(lifecycleScope)
combine(
readerPreferences.fullscreen.changes(),
readerPreferences.drawUnderCutout.changes(),
) { fullscreen, drawUnderCutout -> fullscreen to drawUnderCutout }
.onEach { (fullscreen, drawUnderCutout) ->
updateViewerInset(fullscreen, drawUnderCutout)
}
.launchIn(lifecycleScope)
}
/**
* Sets the display profile to [path].
*/
private fun setDisplayProfile(path: String) {
val file = UniFile.fromUri(baseContext, path.toUri())
if (file != null && file.exists()) {
val inputStream = file.openInputStream()
val outputStream = ByteArrayOutputStream()
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
val data = outputStream.toByteArray()
SubsamplingScaleImageView.setDisplayProfile(data)
ImageDecoder.displayProfile = data
}
}
/**
* Sets the keep screen on mode according to [enabled].
*/
private fun setKeepScreenOn(enabled: Boolean) {
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* Sets the custom brightness overlay according to [enabled].
*/
private fun setCustomBrightness(enabled: Boolean) {
if (enabled) {
readerPreferences.customBrightnessValue.changes()
.sample(0.1.seconds)
.onEach(::setCustomBrightnessValue)
.launchIn(lifecycleScope)
} else {
setCustomBrightnessValue(0)
}
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
* From 1 to 100 it sets that value as brightness.
* 0 sets system brightness and hides the overlay.
*/
private fun setCustomBrightnessValue(value: Int) {
// Calculate and set reader brightness.
val readerBrightness = when {
value > 0 -> {
value / 100f
}
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
viewModel.setBrightnessOverlayValue(value)
}
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
val paint = if (grayscale || invertedColors) getCombinedPaint(grayscale, invertedColors) else null
binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint)
}
}
return ColorFilter.colorMatrix(matrix)
}
@@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.graphics.withScale
import androidx.core.graphics.withTranslation
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import tachiyomi.core.common.i18n.stringResource
import kotlin.math.abs
class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private var viewPropertyAnimator: ViewPropertyAnimator? = null
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
invalidate()
if (isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
viewPropertyAnimator = animate()
.alpha(1f)
.setDuration(FADE_DURATION)
.withStartAction {
isVisible = true
}
.withEndAction {
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
private val regionPaint = Paint()
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.WHITE
textSize = 64f
}
private val textBorderPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
textSize = 64f
style = Paint.Style.STROKE
strokeWidth = 8f
}
override fun onDraw(canvas: Canvas) {
if (navigation == null) return
navigation?.getRegions()?.forEach { region ->
val rect = region.rectF
// Scale rect from 1f,1f to screen width and height
canvas.withScale(width.toFloat(), height.toFloat()) {
regionPaint.color = region.type.color
drawRect(rect, regionPaint)
}
// Don't want scale anymore because it messes with drawText
// Translate origin to rect start (left, top)
canvas.withTranslation(x = (width * rect.left), y = (height * rect.top)) {
// Calculate center of rect width on screen
val x = width * (abs(rect.left - rect.right) / 2)
// Calculate center of rect height on screen
val y = height * (abs(rect.top - rect.bottom) / 2)
drawText(context.stringResource(region.type.nameRes), x, y, textBorderPaint)
drawText(context.stringResource(region.type.nameRes), x, y, textPaint)
}
}
}
override fun performClick(): Boolean {
super.performClick()
if (viewPropertyAnimator == null && isVisible) {
viewPropertyAnimator = animate()
.alpha(0f)
.setDuration(FADE_DURATION)
.withEndAction {
isVisible = false
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
return true
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
// Hide overlay if user start tapping or swiping
performClick()
return super.onTouchEvent(event)
}
}
private const val FADE_DURATION = 1000L
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.net.Uri
import androidx.annotation.IntRange
import androidx.compose.runtime.Immutable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
@@ -24,6 +25,7 @@ import eu.kanade.domain.manga.model.readingMode
import eu.kanade.domain.source.interactor.GetIncognitoState
import eu.kanade.domain.track.interactor.TrackChapter
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -45,6 +47,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.util.chapter.filterDownloaded
import eu.kanade.tachiyomi.util.chapter.removeDuplicates
import eu.kanade.tachiyomi.util.editCover
@@ -75,7 +78,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope
import mihon.data.cache.ChapterCache
import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -525,6 +527,12 @@ class ReaderViewModel(
eventChannel.trySend(Event.ReloadViewerChapters)
}
fun onViewerLoaded(viewer: Viewer?) {
mutableState.update {
it.copy(viewer = viewer)
}
}
/**
* Called every time a page changes on the reader. Used to mark the flag of chapters being
* read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this
@@ -912,6 +920,10 @@ class ReaderViewModel(
mutableState.update { it.copy(menuVisible = visible) }
}
fun showLoadingDialog() {
mutableState.update { it.copy(dialog = Dialog.Loading) }
}
fun openReadingModeSelectDialog() {
mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) }
}
@@ -932,6 +944,10 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) }
}
fun setBrightnessOverlayValue(value: Int) {
mutableState.update { it.copy(brightnessOverlayValue = value) }
}
/**
* Saves the image of the selected page on the pictures directory and notifies the UI of the result.
* There's also a notification to allow sharing the image somewhere else or deleting it.
@@ -1094,8 +1110,14 @@ class ReaderViewModel(
val bookmarked: Boolean = false,
val isLoadingAdjacentChapter: Boolean = false,
val currentPage: Int = -1,
/**
* Viewer used to display the pages (pager, webtoon, ...).
*/
val viewer: Viewer? = null,
val dialog: Dialog? = null,
val menuVisible: Boolean = false,
@IntRange(from = -100, to = 100) val brightnessOverlayValue: Int = 0,
) {
val currentChapter: ReaderChapter?
get() = viewerChapters?.currChapter
@@ -1105,6 +1127,7 @@ class ReaderViewModel(
}
sealed interface Dialog {
data object Loading : Dialog
data object Settings : Dialog
data object ReadingModeSelect : Dialog
data object OrientationModeSelect : Dialog
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source
@@ -12,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader
import mihon.data.cache.ChapterCache
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.model.Manga
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@@ -15,8 +16,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mihon.data.cache.ChapterCache
import java.io.FileNotFoundException
import java.util.concurrent.ConcurrentHashMap
/**
@@ -42,8 +41,14 @@ internal class HttpPageLoader(
* otherwise fallbacks to network.
*/
override suspend fun getPages(): List<ReaderPage> {
val pages = chapterCache.getPageList(chapter.chapter)
?: source.getPageList(chapter.chapter.toSChapter())
val pages = try {
chapterCache.getPageListFromCache(chapter.chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
source.getPageList(chapter.chapter.toSChapter())
}
return pages.mapIndexed { index, page ->
// Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl).also {
@@ -109,7 +114,7 @@ internal class HttpPageLoader(
try {
// Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageList(chapter.chapter, pagesToSave)
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
@@ -225,32 +230,15 @@ internal class HttpPageLoader(
}
val imageUrl = page.imageUrl!!
val file = chapterCache.getImageFile(imageUrl).takeUnless { force } ?: run {
val live = page.downloadStream
if (force || !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.State.DownloadImage
try {
chapterCache.putImage(imageUrl, source.getImage(page), live)
.also { live?.finish() }
} catch (e: Throwable) {
live?.finish(e)
throw e
}
val imageResponse = source.getImage(page)
chapterCache.putImageToCache(imageUrl, imageResponse)
}
page.downloadStream = null
page.stream = {
try {
file.inputStream()
} catch (e: FileNotFoundException) {
// Gone from the cache since, so it's fetched again and shown once it's back
page.status = Page.State.Queue
launchLoadOnce(page)
throw e
}
}
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.State.Ready
} catch (e: Throwable) {
page.downloadStream = null
if (e is CancellationException) {
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
// the queue, otherwise it's stuck in a state no later pass will pick up.
@@ -1,130 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.model
import java.io.IOException
import java.io.InputStream
/**
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
*/
class DownloadStream {
private val lock = Object()
private var data = ByteArray(1 shl 16)
private var size = 0
private var done = false
private var error: Throwable? = null
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
fun reserve(bytes: Long) {
synchronized(lock) {
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
try {
data = data.copyOf(bytes.toInt())
} catch (e: OutOfMemoryError) {
// Only a hint.
}
}
}
}
fun append(src: ByteArray, offset: Int, length: Int) {
synchronized(lock) {
if (error != null || done) return
val needed = size.toLong() + length
if (needed > MAX_BYTES) {
// Too big to decode while downloading; the whole-file decode takes over.
drop("Download too large to stream")
return
}
if (needed > data.size) {
data = try {
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
} catch (e: OutOfMemoryError) {
// As too large: the download mustn't fail over this copy.
drop("Out of memory buffering the download")
return
}
}
System.arraycopy(src, offset, data, size, length)
size += length
lock.notifyAll()
}
}
/** Ends the bytes; readers throw [error] if given. */
fun finish(error: Throwable? = null) {
synchronized(lock) {
done = true
if (this.error == null) this.error = error
lock.notifyAll()
}
}
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
fun reader(): Reader = Reader()
// Rather than hold a second copy of the page.
private fun drop(why: String) {
if (error == null) error = IOException(why)
data = ByteArray(0)
size = 0
lock.notifyAll()
}
private companion object {
// As the decoder's own input limit.
const val MAX_BYTES = 1L shl 30
const val MAX_RESERVE = 64L shl 20
}
inner class Reader : InputStream() {
private var position = 0
/** All read; throws if the download failed. */
val ended: Boolean
get() = synchronized(lock) {
checkError()
done && position >= size
}
override fun read(): Int {
val b = ByteArray(1)
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
synchronized(lock) {
if (len == 0) return 0
while (position >= size && !done && error == null) lock.wait()
checkError()
if (position >= size) return -1
val n = minOf(len, size - position)
System.arraycopy(data, position, b, off, n)
position += n
return n
}
}
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
fun await(min: Int, timeoutMs: Long) {
synchronized(lock) {
val until = System.nanoTime() + timeoutMs * 1_000_000
while (size - position < min && !done && error == null) {
val left = (until - System.nanoTime()) / 1_000_000
if (left <= 0) return
lock.wait(left)
}
}
}
override fun close() {
synchronized(lock) { drop("No longer read") }
}
private fun checkError() {
error?.let { throw IOException("Download failed", it) }
}
}
}
@@ -11,8 +11,4 @@ open class ReaderPage(
) : Page(index, url, imageUrl, null) {
open lateinit var chapter: ReaderChapter
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
@Volatile
var downloadStream: DownloadStream? = null
}
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.setting
/**
* Settings for the reader as a whole and for every viewer alike.
*/
data class GeneralSettings(
val theme: ReaderPreferences.ReaderTheme,
val keepScreenOn: Boolean,
val grayscale: Boolean,
val invertedColors: Boolean,
val fullscreen: Boolean,
val drawUnderCutout: Boolean,
val longTapEnabled: Boolean,
val usePageTransitions: Boolean,
val doubleTapAnimDuration: Int,
val volumeKeysEnabled: Boolean,
val volumeKeysInverted: Boolean,
val alwaysShowChapterTransition: Boolean,
val navigationOverlayOnStart: Boolean,
) {
val automaticBackground: Boolean
get() = theme == ReaderPreferences.ReaderTheme.AUTOMATIC
}
@@ -6,8 +6,6 @@ import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShader
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShaders
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@@ -144,11 +142,6 @@ class ReaderPreferences(
false,
)
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
"pref_dual_page_view",
DualPageView.NEVER,
)
// endregion
// region Color filter
@@ -206,43 +199,6 @@ class ReaderPreferences(
// endregion
// region WebGpu
val transitionAnimation: Preference<TransitionAnimation> =
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC)
val transitionAnimationDual: Preference<TransitionAnimation> =
preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC)
val cutoutMode: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID)
val cutoutModeDual: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE)
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
val disableDoubleTapGestures: Preference<Boolean> =
preferenceStore.getBoolean("webgpu_disable_double_tap_gestures", false)
/** In run order. */
val imageFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_image_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
/** In run order. */
val tileFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_tile_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
// endregion
enum class FlashColor {
BLACK,
WHITE,
@@ -255,81 +211,11 @@ class ReaderPreferences(
val shouldInvertVertical: Boolean = false,
) {
NONE(MR.strings.tapping_inverted_none),
HORIZONTAL(
MR.strings.tapping_inverted_horizontal,
shouldInvertHorizontal = true,
),
VERTICAL(
MR.strings.tapping_inverted_vertical,
shouldInvertVertical = true,
),
HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true),
VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true),
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
}
/**
* The [readerTheme] preference, stored as its [value].
*/
enum class ReaderTheme(val value: Int) {
WHITE(0),
BLACK(1),
GRAY(2),
AUTOMATIC(3),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: BLACK
}
}
/**
* The [imageScaleType] preference, stored as its [value], which is also the scale type the image view takes.
*/
enum class ScaleType(val value: Int) {
FIT_SCREEN(1),
STRETCH(2),
FIT_WIDTH(3),
FIT_HEIGHT(4),
ORIGINAL_SIZE(5),
SMART_FIT(6),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: FIT_SCREEN
}
}
/**
* The [zoomStart] preference, stored as its [value].
*/
enum class ZoomStartType(val value: Int) {
AUTOMATIC(1),
LEFT(2),
RIGHT(3),
CENTER(4),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: CENTER
}
}
/**
* The [navigationModePager] and [navigationModeWebtoon] preferences, stored as their [value].
*/
enum class NavigationMode(val value: Int) {
DEFAULT(0),
L_SHAPED(1),
KINDLISH(2),
EDGE(3),
RIGHT_AND_LEFT(4),
DISABLED(5),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: DEFAULT
}
}
enum class ReaderHideThreshold(val threshold: Int) {
HIGHEST(5),
HIGH(13),
@@ -337,43 +223,6 @@ class ReaderPreferences(
LOWEST(47),
}
enum class TransitionAnimation(val titleRes: StringResource) {
BASIC(MR.strings.transition_animation_basic),
FLIP(MR.strings.transition_animation_flip),
FLIP_LEFT(MR.strings.transition_animation_flip_left),
FLIP_RIGHT(
MR.strings.transition_animation_flip_right,
),
STACK_LEFT(MR.strings.transition_animation_stack_left),
STACK_RIGHT(MR.strings.transition_animation_stack_right),
STACK_UP(
MR.strings.transition_animation_stack_up,
),
STACK_DOWN(MR.strings.transition_animation_stack_down),
SPHERE(MR.strings.transition_animation_sphere),
CUBE_INSIDE(
MR.strings.transition_animation_cube_inside,
),
CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside),
FADE(MR.strings.transition_animation_fade),
FADE_WHITE(
MR.strings.transition_animation_fade_white,
),
NONE(MR.strings.transition_animation_none),
}
enum class CutoutMode(val titleRes: StringResource) {
IGNORE(MR.strings.cutout_mode_ignore),
AVOID(MR.strings.cutout_mode_avoid),
SHIFT(MR.strings.cutout_mode_shift),
}
enum class DualPageView(val titleRes: StringResource) {
NEVER(MR.strings.dual_page_view_never),
ALWAYS(MR.strings.dual_page_view_always),
WIDE(MR.strings.dual_page_view_wide),
}
companion object {
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
@@ -398,13 +247,6 @@ class ReaderPreferences(
MR.strings.scale_type_smart_fit,
)
val ImageScaleTypeWebGpuViewer = listOf(
MR.strings.scale_type_fit_screen,
MR.strings.scale_type_fit_width,
MR.strings.scale_type_fit_height,
MR.strings.scale_type_original_size,
)
val ZoomStart = listOf(
MR.strings.zoom_start_automatic,
MR.strings.zoom_start_left,
@@ -2,317 +2,27 @@ package eu.kanade.tachiyomi.ui.reader.setting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
import eu.kanade.tachiyomi.ui.reader.viewer.LongStripSettings
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationSettings
import eu.kanade.tachiyomi.ui.reader.viewer.PagedSettings
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.pager.SimplePagedSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuLongStripSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuPagedSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.SimpleLongStripSettings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import tachiyomi.core.common.preference.Preference
import kotlin.time.Duration.Companion.seconds
/**
* The reader's settings, each as one value re-read whenever a preference it's made of changes, so the activity and
* viewers apply a change in one place instead of each preference calling back.
*/
class ReaderSettingsViewModel(
private val readerViewModel: ReaderViewModel,
readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit,
val preferences: ReaderPreferences,
private val basePreferences: BasePreferences,
) : ViewModel() {
val mangaFlow = readerViewModel.state
.map { it.manga }
val viewerFlow = readerState
.map { it.viewer }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
/**
* The reading mode the viewer is made for, with the default resolved.
*/
val readingMode = combine(mangaFlow, preferences.defaultReadingMode.changes()) { manga, default ->
ReadingMode.fromPreference(manga?.readingMode?.toInt())
.takeIf { it != ReadingMode.DEFAULT }
?: ReadingMode.fromPreference(default)
}
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
ReadingMode.fromPreference(readerViewModel.getMangaReadingMode()),
)
/**
* Whether the viewers are the WebGPU ones.
*/
val isWebGpu = basePreferences.highQualityRenderer
val general = settings(
preferences.readerTheme,
preferences.keepScreenOn,
preferences.grayscale,
preferences.invertedColors,
preferences.fullscreen,
preferences.drawUnderCutout,
preferences.readWithLongTap,
preferences.pageTransitions,
preferences.doubleTapAnimSpeed,
preferences.readWithVolumeKeys,
preferences.readWithVolumeKeysInverted,
preferences.alwaysShowChapterTransition,
preferences.showNavigationOverlayOnStart,
) {
GeneralSettings(
theme = ReaderPreferences.ReaderTheme.fromValue(readerTheme.get()),
keepScreenOn = keepScreenOn.get(),
grayscale = grayscale.get(),
invertedColors = invertedColors.get(),
fullscreen = fullscreen.get(),
drawUnderCutout = drawUnderCutout.get(),
longTapEnabled = readWithLongTap.get(),
usePageTransitions = pageTransitions.get(),
doubleTapAnimDuration = doubleTapAnimSpeed.get(),
volumeKeysEnabled = readWithVolumeKeys.get(),
volumeKeysInverted = readWithVolumeKeysInverted.get(),
alwaysShowChapterTransition = alwaysShowChapterTransition.get(),
navigationOverlayOnStart = showNavigationOverlayOnStart.get(),
)
}
/**
* The custom brightness, 0 when it's off.
*/
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val brightness: StateFlow<Int> = preferences.customBrightness.changes()
.flatMapLatest { enabled ->
if (enabled) preferences.customBrightnessValue.changes().sample(0.1.seconds) else flowOf(0)
}
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
if (preferences.customBrightness.get()) preferences.customBrightnessValue.get() else 0,
)
/**
* Settings every paged viewer uses.
*/
val paged = settings(
preferences.imageScaleType,
preferences.zoomStart,
preferences.cropBorders,
preferences.landscapeZoom,
preferences.navigateToPan,
preferences.navigationModePager,
preferences.pagerNavInverted,
) {
PagedSettings(
page = PagedSettings.Page(
imageScaleType = ReaderPreferences.ScaleType.fromValue(imageScaleType.get()),
zoomStart = ReaderPreferences.ZoomStartType.fromValue(zoomStart.get()),
cropBorders = cropBorders.get(),
landscapeZoom = landscapeZoom.get(),
),
navigateToPan = navigateToPan.get(),
navigation = NavigationSettings(
ReaderPreferences.NavigationMode.fromValue(navigationModePager.get()),
pagerNavInverted.get(),
),
)
}
/**
* Settings every long strip viewer uses.
*/
val longStrip = settings(
preferences.cropBordersWebtoon,
preferences.webtoonDisableZoomOut,
) {
LongStripSettings(
cropBorders = cropBordersWebtoon.get(),
zoomOutDisabled = webtoonDisableZoomOut.get(),
)
}
/**
* Settings only the pager viewers use.
*/
val simplePaged = settings(
preferences.dualPageSplitPaged,
preferences.dualPageInvertPaged,
preferences.dualPageRotateToFit,
preferences.dualPageRotateToFitInvert,
) {
SimplePagedSettings(
dualPageSplit = dualPageSplitPaged.get(),
dualPageInvert = dualPageInvertPaged.get(),
dualPageRotateToFit = dualPageRotateToFit.get(),
dualPageRotateToFitInvert = dualPageRotateToFitInvert.get(),
)
}
/**
* Settings only the webtoon viewers use.
*/
val simpleLongStrip = settings(
preferences.webtoonSidePadding,
preferences.dualPageSplitWebtoon,
preferences.dualPageInvertWebtoon,
preferences.dualPageRotateToFitWebtoon,
preferences.dualPageRotateToFitInvertWebtoon,
preferences.webtoonDoubleTapZoomEnabled,
preferences.navigationModeWebtoon,
preferences.webtoonNavInverted,
) {
SimpleLongStripSettings(
page = SimpleLongStripSettings.Page(
sidePadding = webtoonSidePadding.get(),
dualPageSplit = dualPageSplitWebtoon.get(),
dualPageInvert = dualPageInvertWebtoon.get(),
dualPageRotateToFit = dualPageRotateToFitWebtoon.get(),
dualPageRotateToFitInvert = dualPageRotateToFitInvertWebtoon.get(),
),
doubleTapZoom = webtoonDoubleTapZoomEnabled.get(),
navigation = NavigationSettings(
ReaderPreferences.NavigationMode.fromValue(navigationModeWebtoon.get()),
webtoonNavInverted.get(),
),
)
}
/**
* Settings only the paged WebGPU viewers use.
*/
val webgpuPaged = settings(
preferences.dualPageView,
preferences.transitionAnimation,
preferences.transitionAnimationDual,
preferences.cutoutMode,
preferences.cutoutModeDual,
preferences.disableDoubleTapGestures,
preferences.imageFilters,
preferences.tileFilters,
) {
WebGpuPagedSettings(
page = WebGpuPagedSettings.Page(
dualPageView = dualPageView.get(),
transitionAnimation = transitionAnimation.get(),
transitionAnimationDual = transitionAnimationDual.get(),
cutoutMode = cutoutMode.get(),
cutoutModeDual = cutoutModeDual.get(),
),
common = WebGpuSettings(
doubleTapGesturesDisabled = disableDoubleTapGestures.get(),
imageFilters = imageFilters.get(),
tileFilters = tileFilters.get(),
),
)
}
/**
* Settings only the long strip WebGPU viewer uses.
*/
val webgpuLongStrip = settings(
preferences.continuousMinWidth,
preferences.continuousGap,
preferences.disableDoubleTapGestures,
preferences.imageFilters,
preferences.tileFilters,
) {
WebGpuLongStripSettings(
page = WebGpuLongStripSettings.Page(
continuousMinWidth = continuousMinWidth.get(),
continuousGap = continuousGap.get(),
),
common = WebGpuSettings(
doubleTapGesturesDisabled = disableDoubleTapGestures.get(),
imageFilters = imageFilters.get(),
tileFilters = tileFilters.get(),
),
)
}
private val viewerMode = MutableStateFlow<ViewerMode?>(null)
/**
* Tap zones of the open viewer. Null until a viewer is made.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val navigation: StateFlow<ViewerNavigation?> = viewerMode
.flatMapLatest { viewerMode ->
if (viewerMode == null) return@flatMapLatest flowOf(null)
val usesWebtoonZones = viewerMode.readingMode.type == ReadingMode.ViewerType.Webtoon &&
!viewerMode.isWebGpu
val settings = if (usesWebtoonZones) {
simpleLongStrip.map { it.navigation }
} else {
paged.map { it.navigation }
}
val isReversed = viewerMode.readingMode == ReadingMode.RIGHT_TO_LEFT
val defaultNavigation: (ReaderPreferences.TappingInvertMode, Boolean) -> ViewerNavigation =
if (viewerMode.readingMode.direction == ReadingMode.Direction.Vertical) {
::LNavigation
} else {
::RightAndLeftNavigation
}
settings.distinctUntilChanged().map { it.navigator(isReversed, defaultNavigation) }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/**
* Called when a viewer is made for [readingMode].
*/
fun setViewerReadingMode(readingMode: ReadingMode) {
viewerMode.value = ViewerMode(readingMode, isWebGpu.get())
}
fun setReadingMode(readingMode: ReadingMode) = readerViewModel.setMangaReadingMode(readingMode)
fun setOrientation(orientation: ReaderOrientation) = readerViewModel.setMangaOrientationType(orientation)
/**
* Whether a viewer must show its tap zones because the reader is new to the user, which it is only once.
*/
fun takeNavigationOverlayForNewUser(): Boolean {
val show = preferences.showNavigationOverlayNewUser.get()
if (show) {
preferences.showNavigationOverlayNewUser.set(false)
}
return show
}
/**
* A value [read] from [sources], kept current as any of them changes. Started eagerly, so a viewer reading it
* before collecting gets what the preferences hold now.
*/
private fun <T> settings(vararg sources: Preference<*>, read: ReaderPreferences.() -> T): StateFlow<T> {
return sources.map { it.changes() }
.merge()
.map { preferences.read() }
.stateIn(viewModelScope, SharingStarted.Eagerly, preferences.read())
}
private data class ViewerMode(
val readingMode: ReadingMode,
val isWebGpu: Boolean,
)
val mangaFlow = readerState
.map { it.manga }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
}
@@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
@@ -77,16 +74,6 @@ enum class ReadingMode(
}
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
if (activity.appGraph.basePreferences.highQualityRenderer.get()) {
return when (fromPreference(preference)) {
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
}
}
return when (fromPreference(preference)) {
LEFT_TO_RIGHT -> L2RPagerViewer(activity)
RIGHT_TO_LEFT -> R2LPagerViewer(activity)
@@ -1,11 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* How a viewer shows a wide page.
*/
interface DualPageSettings {
val dualPageSplit: Boolean
val dualPageInvert: Boolean
val dualPageRotateToFit: Boolean
val dualPageRotateToFitInvert: Boolean
}
@@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* Settings every long strip viewer uses.
*/
data class LongStripSettings(
val cropBorders: Boolean,
val zoomOutDisabled: Boolean,
)
@@ -1,89 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/**
* The image of [page] as a pager shows it: a wide page rotated to fit or split in two, the half on the reading
* side first, so [isLeftToRight] tells which. [onPageSplit] is told of the [InsertPage] for the second half.
*/
fun DualPageSettings.processPagedImage(
page: ReaderPage,
imageSource: BufferedSource,
isLeftToRight: Boolean,
onPageSplit: (InsertPage) -> Unit,
): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (!dualPageSplit) {
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageSource, page, isLeftToRight)
}
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageSource
}
onPageSplit(InsertPage(page))
return splitInHalf(imageSource, page, isLeftToRight)
}
/**
* The image of a page as a strip shows it: a wide page rotated to fit, or split with its halves one above the other.
*/
fun DualPageSettings.processStripImage(imageSource: BufferedSource): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageSource
}
private fun DualPageSettings.rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
private fun DualPageSettings.splitInHalf(
imageSource: BufferedSource,
page: ReaderPage,
isLeftToRight: Boolean,
): BufferedSource {
var side = when {
isLeftToRight && page is InsertPage -> ImageUtil.Side.RIGHT
!isLeftToRight && page is InsertPage -> ImageUtil.Side.LEFT
isLeftToRight && page !is InsertPage -> ImageUtil.Side.LEFT
else -> ImageUtil.Side.RIGHT
}
if (dualPageInvert) {
side = when (side) {
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
}
}
return ImageUtil.splitInHalf(imageSource, side)
}
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
/**
* Settings every paged viewer uses.
*/
data class PagedSettings(
val page: Page,
val navigateToPan: Boolean,
val navigation: NavigationSettings,
) {
/**
* What the pages are made with, so a change remakes them.
*/
data class Page(
val imageScaleType: ReaderPreferences.ScaleType,
val zoomStart: ReaderPreferences.ZoomStartType,
val cropBorders: Boolean,
val landscapeZoom: Boolean,
)
}
@@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import com.google.android.material.R
import com.google.android.material.button.MaterialButton
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
/**
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
* the pager, but this class disables that behavior to allow clickable buttons.
*/
class ReaderButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.materialButtonStyle,
) : MaterialButton(context, attrs, defStyleAttr) {
var viewer: PagerViewer? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
viewer?.pager?.setGestureDetectorEnabled(false)
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
viewer?.pager?.setGestureDetectorEnabled(true)
}
return super.onTouchEvent(event)
}
}
@@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.reader.ReaderError
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
/**
* [ReaderError] for a [page] that failed with [error]: Retry asks its loader again, and an image from the web can
* be opened in a WebView with the cookies of the source [sourceId].
*/
@Composable
fun ReaderPageError(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val context = LocalContext.current
val imageUrl = page.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
ReaderError(
message = with(context) { error?.formattedMessage },
onRetry = { page.chapter.pageLoader?.retryPage(page) },
onOpenInWebView = imageUrl?.let {
{ context.startActivity(WebViewActivity.newIntent(context, it, sourceId)) }
},
modifier = modifier,
onButtonPressChange = onButtonPressChange,
)
}
/**
* [ReaderPageError] for the view-based viewers, filling the view, in the theme of its reader-themed [context].
*/
class ReaderPageErrorView(context: Context) : AbstractComposeView(context) {
private var data by mutableStateOf<Data?>(null)
fun bind(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
data = Data(page, error, sourceId, onButtonPressChange)
}
@Composable
override fun Content() {
val data = data ?: return
ReaderThemedContent(context) {
ReaderPageError(
page = data.page,
error = data.error,
sourceId = data.sourceId,
modifier = Modifier.fillMaxSize(),
onButtonPressChange = data.onButtonPressChange,
)
}
}
private data class Data(
val page: ReaderPage,
val error: Throwable?,
val sourceId: Long?,
val onButtonPressChange: ((pressed: Boolean) -> Unit)?,
)
}
@@ -1,8 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Animatable
@@ -15,7 +13,6 @@ import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes
import androidx.annotation.CallSuper
import androidx.annotation.StyleRes
@@ -44,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/**
* A wrapper view for showing page image.
@@ -63,30 +61,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
private var pageView: View? = null
/**
* The reader's grayscale and inverted colours for the page image alone, so the transition and error views
* keep theirs - see [LocalPageColorFilter].
*/
var pageColorFilter: ColorFilter? = null
set(value) {
if (field == value) return
field = value
applyPageColorFilter()
}
private fun applyPageColorFilter() {
val filter = pageColorFilter
when (val view = pageView) {
is ImageView -> view.colorFilter = filter
is WebtoonStripImageView -> view.colorFilter = filter
// Drawn with its own paints: a layer takes the filter instead, as wide as the screen it fills.
is SubsamplingScaleImageView -> view.setLayerType(
if (filter == null) LAYER_TYPE_NONE else LAYER_TYPE_HARDWARE,
filter?.let { Paint().apply { colorFilter = it } },
)
}
}
private var stripRequest: Disposable? = null
private var config: Config? = null
@@ -267,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
removeView(pageView)
pageView = SubsamplingScaleImageView(context).apply {
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumTileDpi(180)
@@ -284,7 +259,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
setOnClickListener { this@ReaderPageImageView.onViewClicked() }
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
}
private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
@@ -328,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
isVisible = true
}
is BufferedSource -> {
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true
}
@@ -343,7 +318,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
pageView = WebtoonStripImageView(context)
addView(pageView, MATCH_PARENT, WRAP_CONTENT)
applyPageColorFilter()
}
private fun setStripImage(data: BufferedSource, config: Config) {
@@ -420,7 +394,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
}
private fun setAnimatedImage(
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.theme.TachiyomiTheme
/**
* [content] in the app's theme under the reader's own night mode, from [readerThemedContext].
*
* Pages sit on the reader background, so a light app over a black reader would otherwise draw dark text on black.
* [TachiyomiTheme] reads that from the configuration, which the view-based viewers got by inflating their views
* from a reader-themed context.
*/
@Composable
fun ReaderThemedContent(readerThemedContext: Context, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalContext provides readerThemedContext,
LocalConfiguration provides readerThemedContext.resources.configuration,
) {
TachiyomiTheme(content = content)
}
}
@@ -1,86 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.reader.reportPress
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderTransitionPage(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit,
modifier: Modifier = Modifier,
statusPadding: PaddingValues = PaddingValues(),
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (downloadState != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition,
downloadState.currChapterDownloaded,
downloadState.goingToChapterDownloaded,
)
}
}
val toChapter = transition.to ?: return@Column
val state by toChapter.stateFlow.collectAsState()
when (val state = state) {
is ReaderChapter.State.Loading -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = stringResource(MR.strings.transition_pages_loading),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
}
is ReaderChapter.State.Error -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(MR.strings.transition_pages_error, state.error.message ?: ""),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = { onRetry(toChapter) },
modifier = Modifier.reportPress(onButtonPressChange),
) {
Text(stringResource(MR.strings.action_retry))
}
}
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {}
}
}
}
@@ -2,58 +2,91 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
/**
* [ReaderTransitionPage] for the view-based viewers, in the theme of its reader-themed [context].
*/
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
AbstractComposeView(context, attrs) {
private var data: Data? by mutableStateOf(null)
init {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
fun bind(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit = {},
statusPadding: PaddingValues = PaddingValues(),
) {
data = Data(transition, downloadState, onRetry, onButtonPressChange, statusPadding)
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
data = if (manga != null && source != null) {
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
Data(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
goingToChapter?.let {
downloadManager.isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
downloadManager.isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
} else {
null
}
}
@Composable
override fun Content() {
val data = data ?: return
LaunchedEffect(data.downloadState) { data.downloadState?.checkDisk() }
ReaderThemedContent(context) {
ReaderTransitionPage(
transition = data.transition,
downloadState = data.downloadState,
onRetry = data.onRetry,
onButtonPressChange = data.onButtonPressChange,
statusPadding = data.statusPadding,
)
data?.let {
val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
}
TachiyomiTheme {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = goingToChapterDownloaded,
)
}
}
}
}
private data class Data(
val transition: ChapterTransition,
val downloadState: TransitionDownloadState?,
val onRetry: (ReaderChapter) -> Unit,
val onButtonPressChange: (pressed: Boolean) -> Unit,
val statusPadding: PaddingValues,
val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: Boolean,
val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
)
}
@@ -1,56 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
class TransitionDownloadState(
val currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean,
private val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
) {
var goingToChapterDownloaded by mutableStateOf(goingToChapterDownloaded)
private set
suspend fun checkDisk() {
isGoingToChapterOnDisk?.let { goingToChapterDownloaded = it() }
}
}
fun DownloadManager.transitionDownloadState(
transition: ChapterTransition,
manga: Manga?,
source: Source?,
): TransitionDownloadState? {
if (manga == null || source == null) return null
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
return TransitionDownloadState(
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
goingToChapter?.let {
isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
}
@@ -1,12 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@@ -16,10 +12,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
interface Viewer {
/**
* Draws this viewer.
* Returns the view this viewer uses.
*/
@Composable
fun Content(modifier: Modifier)
fun getView(): View
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
@@ -37,37 +32,14 @@ interface Viewer {
fun moveToPage(page: ReaderPage)
/**
* Moves forward through the pages.
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun moveForward()
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Moves backward through the pages.
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun moveBackward()
}
/**
* The reader's grayscale and inverted colours, for page images only: transition, loading and error pages keep
* theirs. Null when both are off.
*/
val LocalPageColorFilter = compositionLocalOf<ColorFilter?> { null }
/**
* Turns pages with the mouse wheel instead of letting the content scroll.
*/
fun Modifier.mouseWheelPaging(viewer: Viewer): Modifier = pointerInput(viewer) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.type != PointerEventType.Scroll) continue
val delta = event.changes.first().scrollDelta.y
when {
delta > 0 -> viewer.moveForward()
delta < 0 -> viewer.moveBackward()
else -> continue
}
event.changes.forEach { it.consume() }
}
}
fun handleGenericMotionEvent(event: MotionEvent): Boolean
}
@@ -0,0 +1,90 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import tachiyomi.core.common.preference.Preference
/**
* Common configuration for all viewers.
*/
abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val scope: CoroutineScope) {
var imagePropertyChangedListener: (() -> Unit)? = null
var navigationModeChangedListener: (() -> Unit)? = null
var tappingInverted = ReaderPreferences.TappingInvertMode.NONE
var longTapEnabled = true
var usePageTransitions = false
var doubleTapAnimDuration = 500
var volumeKeysEnabled = false
var volumeKeysInverted = false
var alwaysShowChapterTransition = true
var navigationMode = 0
protected set
var forceNavigationOverlay = false
var navigationOverlayOnStart = false
var dualPageSplit = false
protected set
var dualPageInvert = false
protected set
var dualPageRotateToFit = false
protected set
var dualPageRotateToFitInvert = false
protected set
abstract var navigator: ViewerNavigation
protected set
init {
readerPreferences.readWithLongTap
.register({ longTapEnabled = it })
readerPreferences.pageTransitions
.register({ usePageTransitions = it })
readerPreferences.doubleTapAnimSpeed
.register({ doubleTapAnimDuration = it })
readerPreferences.readWithVolumeKeys
.register({ volumeKeysEnabled = it })
readerPreferences.readWithVolumeKeysInverted
.register({ volumeKeysInverted = it })
readerPreferences.alwaysShowChapterTransition
.register({ alwaysShowChapterTransition = it })
forceNavigationOverlay = readerPreferences.showNavigationOverlayNewUser.get()
if (forceNavigationOverlay) {
readerPreferences.showNavigationOverlayNewUser.set(false)
}
readerPreferences.showNavigationOverlayOnStart
.register({ navigationOverlayOnStart = it })
}
protected abstract fun defaultNavigation(): ViewerNavigation
abstract fun updateNavigation(navigationMode: Int)
fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {},
) {
changes()
.onEach { valueAssignment(it) }
.distinctUntilChanged()
.onEach { onChanged(it) }
.launchIn(scope)
}
}
@@ -1,43 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
/**
* The items of a viewer for [chapters], top to bottom: the previous chapter's pages, its transition, [currentPages],
* then the next transition and chapter's pages, so moving between chapters is seamless. A transition is left out
* when its chapter is loaded and adjacent, unless [forceTransition].
*/
fun viewerItems(
chapters: ViewerChapters,
forceTransition: Boolean,
currentPages: List<ReaderPage>? = chapters.currChapter.pages,
): MutableList<Any> {
val items = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition
chapters.prevChapter?.pages?.let(items::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
items.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter.
currentPages?.let(items::addAll)
// Add next chapter transition and pages.
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
items.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
chapters.nextChapter?.pages?.let(items::addAll)
return items
}
@@ -1,75 +1,54 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import android.graphics.Color
import android.graphics.PointF
import android.graphics.RectF
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.invert
import tachiyomi.i18n.MR
/**
* Tap zones of a viewer. [isReversed] is set for right to left books, where the right side of the
* screen goes back.
*/
abstract class ViewerNavigation(
val invertMode: ReaderPreferences.TappingInvertMode,
val isReversed: Boolean,
) {
abstract class ViewerNavigation {
enum class Action { MENU, FORWARD, BACKWARD }
sealed class NavigationRegion(val nameRes: StringResource, val color: Color) {
data object MENU : NavigationRegion(MR.strings.action_menu, Color(0xCC95818D))
data object PREV : NavigationRegion(MR.strings.nav_zone_prev, Color(0xCCFF7733))
data object NEXT : NavigationRegion(MR.strings.nav_zone_next, Color(0xCC84E296))
data object LEFT : NavigationRegion(MR.strings.nav_zone_left, Color(0xCC7D1128))
data object RIGHT : NavigationRegion(MR.strings.nav_zone_right, Color(0xCCA6CFD5))
sealed class NavigationRegion(val nameRes: StringResource, val color: Int) {
data object MENU : NavigationRegion(MR.strings.action_menu, Color.argb(0xCC, 0x95, 0x81, 0x8D))
data object PREV : NavigationRegion(MR.strings.nav_zone_prev, Color.argb(0xCC, 0xFF, 0x77, 0x33))
data object NEXT : NavigationRegion(MR.strings.nav_zone_next, Color.argb(0xCC, 0x84, 0xE2, 0x96))
data object LEFT : NavigationRegion(MR.strings.nav_zone_left, Color.argb(0xCC, 0x7D, 0x11, 0x28))
data object RIGHT : NavigationRegion(MR.strings.nav_zone_right, Color.argb(0xCC, 0xA6, 0xCF, 0xD5))
}
data class Region(
val rect: Rect,
val rectF: RectF,
val type: NavigationRegion,
) {
fun invert(invertMode: ReaderPreferences.TappingInvertMode): Region {
val horizontal = invertMode.shouldInvertHorizontal
val vertical = invertMode.shouldInvertVertical
if (!horizontal && !vertical) return this
return copy(
rect = Rect(
left = if (horizontal) 1f - rect.right else rect.left,
top = if (vertical) 1f - rect.bottom else rect.top,
right = if (horizontal) 1f - rect.left else rect.right,
bottom = if (vertical) 1f - rect.top else rect.bottom,
),
if (invertMode == ReaderPreferences.TappingInvertMode.NONE) return this
return this.copy(
rectF = this.rectF.invert(invertMode),
)
}
}
protected abstract val regionList: List<Region>
private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
var invertMode: ReaderPreferences.TappingInvertMode = ReaderPreferences.TappingInvertMode.NONE
protected abstract var regionList: List<Region>
/** Returns regions with applied inversion. */
fun getRegions(): List<Region> {
return regionList.map { it.invert(invertMode) }
}
fun getAction(pos: Offset): Action {
val region = getRegions().find { pos in it.rect } ?: return Action.MENU
return actionOf(region.type)
fun getAction(pos: PointF): NavigationRegion {
val x = pos.x
val y = pos.y
val region = getRegions().find { it.rectF.contains(x, y) }
return when {
region != null -> region.type
constantMenuRegion.contains(x, y) -> NavigationRegion.MENU
else -> NavigationRegion.MENU
}
}
fun actionOf(region: NavigationRegion): Action = when (region) {
NavigationRegion.MENU -> Action.MENU
NavigationRegion.NEXT -> Action.FORWARD
NavigationRegion.PREV -> Action.BACKWARD
NavigationRegion.RIGHT -> if (isReversed) Action.BACKWARD else Action.FORWARD
NavigationRegion.LEFT -> if (isReversed) Action.FORWARD else Action.BACKWARD
}
override fun equals(other: Any?): Boolean =
other is ViewerNavigation &&
other.javaClass == javaClass &&
other.invertMode == invertMode &&
other.isReversed == isReversed
override fun hashCode(): Int = (31 * javaClass.hashCode() + invertMode.hashCode()) * 31 + isReversed.hashCode()
}
@@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
/**
* A viewer's tap zones, as the preferences store them.
*/
data class NavigationSettings(
val mode: ReaderPreferences.NavigationMode,
val inverted: ReaderPreferences.TappingInvertMode,
) {
fun navigator(
isReversed: Boolean,
default: (ReaderPreferences.TappingInvertMode, Boolean) -> ViewerNavigation,
): ViewerNavigation {
return when (mode) {
ReaderPreferences.NavigationMode.DEFAULT -> default(inverted, isReversed)
ReaderPreferences.NavigationMode.L_SHAPED -> LNavigation(inverted, isReversed)
ReaderPreferences.NavigationMode.KINDLISH -> KindlishNavigation(inverted, isReversed)
ReaderPreferences.NavigationMode.EDGE -> EdgeNavigation(inverted, isReversed)
ReaderPreferences.NavigationMode.RIGHT_AND_LEFT -> RightAndLeftNavigation(inverted, isReversed)
ReaderPreferences.NavigationMode.DISABLED -> DisabledNavigation(inverted, isReversed)
}
}
}
/**
* Where a page starts zoomed for this setting, where [auto] is what automatic means for the viewer.
*/
fun ReaderPreferences.ZoomStartType.position(auto: ZoomStartPosition): ZoomStartPosition {
return when (this) {
ReaderPreferences.ZoomStartType.AUTOMATIC -> auto
ReaderPreferences.ZoomStartType.LEFT -> ZoomStartPosition.LEFT
ReaderPreferences.ZoomStartType.RIGHT -> ZoomStartPosition.RIGHT
ReaderPreferences.ZoomStartType.CENTER -> ZoomStartPosition.CENTER
}
}
/**
* Runs [action] in [scope] with each value and the one before it, null for the first, so a viewer applies a change
* in one place by comparing the parts it reacts to.
*/
fun <T : Any> Flow<T>.collectChanges(scope: CoroutineScope, action: (old: T?, new: T) -> Unit) {
scope.launch {
var old: T? = null
collect {
action(old, it)
old = it
}
}
}
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,10 +12,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | M | M | M | N: Next
* +---+---+---+
*/
class DisabledNavigation(
invertMode: TappingInvertMode,
isReversed: Boolean,
) : ViewerNavigation(invertMode, isReversed) {
class DisabledNavigation : ViewerNavigation() {
override val regionList: List<Region> = emptyList()
override var regionList: List<Region> = emptyList()
}
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -14,19 +13,19 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | P | N | N: Next
* +---+---+---+
*/
class EdgeNavigation(invertMode: TappingInvertMode, isReversed: Boolean) : ViewerNavigation(invertMode, isReversed) {
class EdgeNavigation : ViewerNavigation() {
override val regionList: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rect = Rect(0f, 0f, 0.33f, 1f),
rectF = RectF(0f, 0f, 0.33f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rect = Rect(0.33f, 0.66f, 0.66f, 1f),
rectF = RectF(0.33f, 0.66f, 0.66f, 1f),
type = NavigationRegion.PREV,
),
Region(
rect = Rect(0.66f, 0f, 1f, 1f),
rectF = RectF(0.66f, 0f, 1f, 1f),
type = NavigationRegion.NEXT,
),
)
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -14,18 +13,15 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | P | N | N | N: Next
* +---+---+---+
*/
class KindlishNavigation(
invertMode: TappingInvertMode,
isReversed: Boolean,
) : ViewerNavigation(invertMode, isReversed) {
class KindlishNavigation : ViewerNavigation() {
override val regionList: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rect = Rect(0.33f, 0.33f, 1f, 1f),
rectF = RectF(0.33f, 0.33f, 1f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rect = Rect(0f, 0.33f, 0.33f, 1f),
rectF = RectF(0f, 0.33f, 0.33f, 1f),
type = NavigationRegion.PREV,
),
)
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -14,23 +13,23 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | N | N | N: Next
* +---+---+---+
*/
open class LNavigation(invertMode: TappingInvertMode, isReversed: Boolean) : ViewerNavigation(invertMode, isReversed) {
open class LNavigation : ViewerNavigation() {
override val regionList: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rect = Rect(0f, 0.33f, 0.33f, 0.66f),
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
type = NavigationRegion.PREV,
),
Region(
rect = Rect(0f, 0f, 1f, 0.33f),
rectF = RectF(0f, 0f, 1f, 0.33f),
type = NavigationRegion.PREV,
),
Region(
rect = Rect(0.66f, 0.33f, 1f, 0.66f),
rectF = RectF(0.66f, 0.33f, 1f, 0.66f),
type = NavigationRegion.NEXT,
),
Region(
rect = Rect(0f, 0.66f, 1f, 1f),
rectF = RectF(0f, 0.66f, 1f, 1f),
type = NavigationRegion.NEXT,
),
)
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -14,18 +13,15 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | M | P | N: Move Left
* +---+---+---+
*/
class RightAndLeftNavigation(
invertMode: TappingInvertMode,
isReversed: Boolean,
) : ViewerNavigation(invertMode, isReversed) {
class RightAndLeftNavigation : ViewerNavigation() {
override val regionList: List<Region> = listOf(
override var regionList: List<Region> = listOf(
Region(
rect = Rect(0f, 0f, 0.33f, 1f),
rectF = RectF(0f, 0f, 0.33f, 1f),
type = NavigationRegion.LEFT,
),
Region(
rect = Rect(0.66f, 0f, 1f, 1f),
rectF = RectF(0.66f, 0f, 1f, 1f),
type = NavigationRegion.RIGHT,
),
)
@@ -0,0 +1,150 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Configuration used by pager viewers.
*/
class PagerConfig(
private val viewer: PagerViewer,
scope: CoroutineScope,
readerPreferences: ReaderPreferences,
) : ViewerConfig(readerPreferences, scope) {
var theme = readerPreferences.readerTheme.get()
private set
var automaticBackground = false
private set
var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null
var imageScaleType = 1
private set
var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT
private set
var imageCropBorders = false
private set
var navigateToPan = false
private set
var landscapeZoom = false
private set
init {
readerPreferences.readerTheme
.register(
{
theme = it
automaticBackground = it == 3
},
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.imageScaleType
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.zoomStart
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
readerPreferences.cropBorders
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigateToPan
.register({ navigateToPan = it })
readerPreferences.landscapeZoom
.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigationModePager
.register({ navigationMode = it }, { updateNavigation(navigationMode) })
readerPreferences.pagerNavInverted
.register({ tappingInverted = it }, { navigator.invertMode = it })
readerPreferences.pagerNavInverted.changes()
.drop(1)
.onEach { navigationModeChangedListener?.invoke() }
.launchIn(scope)
readerPreferences.dualPageSplitPaged
.register(
{ dualPageSplit = it },
{
imagePropertyChangedListener?.invoke()
dualPageSplitChangedListener?.invoke(it)
},
)
readerPreferences.dualPageInvertPaged
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.dualPageRotateToFit
.register(
{ dualPageRotateToFit = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.dualPageRotateToFitInvert
.register(
{ dualPageRotateToFitInvert = it },
{ imagePropertyChangedListener?.invoke() },
)
}
private fun zoomTypeFromPreference(value: Int) {
imageZoomType = when (value) {
// Auto
1 -> when (viewer) {
is L2RPagerViewer -> ReaderPageImageView.ZoomStartPosition.LEFT
is R2LPagerViewer -> ReaderPageImageView.ZoomStartPosition.RIGHT
else -> ReaderPageImageView.ZoomStartPosition.CENTER
}
// Left
2 -> ReaderPageImageView.ZoomStartPosition.LEFT
// Right
3 -> ReaderPageImageView.ZoomStartPosition.RIGHT
// Center
else -> ReaderPageImageView.ZoomStartPosition.CENTER
}
}
override var navigator: ViewerNavigation = defaultNavigation()
set(value) {
field = value.also { it.invertMode = this.tappingInverted }
}
override fun defaultNavigation(): ViewerNavigation {
return when (viewer) {
is VerticalPagerViewer -> LNavigation()
else -> RightAndLeftNavigation()
}
}
override fun updateNavigation(navigationMode: Int) {
navigator = when (navigationMode) {
0 -> defaultNavigation()
1 -> LNavigation()
2 -> KindlishNavigation()
3 -> EdgeNavigation()
4 -> RightAndLeftNavigation()
5 -> DisabledNavigation()
else -> defaultNavigation()
}
navigationModeChangedListener?.invoke()
}
}
@@ -1,121 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
/**
* Items of a pager, where [ViewerChapters] updates are posted, inverted when the pager [isReversed] reads right to
* left. Wide pages split in two get an [InsertPage] for their second half.
*/
class PagerItems(private val isReversed: Boolean) {
/**
* List of currently set items.
*/
var items: List<Any> by mutableStateOf(emptyList())
private set
/**
* Holds preprocessed items so they don't get removed when changing chapter
*/
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
var currentChapter: ReaderChapter? = null
private set
/**
* Updates the items with the given [chapters] while the pager is at [currentPosition], and returns where it must
* go next.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, currentPosition: Int): Update {
var insertPageLastPage: InsertPage? = null
val currPages = chapters.currChapter.pages?.toMutableList()?.also { pages ->
val lastPage = pages.last()
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.forEach { page ->
if (lastPage.index == page.index) {
insertPageLastPage = page
}
pages.add(page.index + 1, page)
}
}
val newItems = viewerItems(chapters, forceTransition, currPages)
currentChapter = chapters.currChapter
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
val currentItem = items.getOrNull(currentPosition)
if (isReversed) {
newItems.reverse()
}
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
items = newItems
// The pager only follows the pages it has laid out, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val position = items.indexOf(currentItem).takeIf { it != -1 && it != currentPosition }
return Update(position, insertPageLastPage)
}
/**
* Adds [newPage], the second half of [currentPage], and returns whether the items changed.
*/
fun onPageSplit(currentPage: Any?, newPage: InsertPage): Boolean {
if (currentPage !is ReaderPage) return false
val currentIndex = items.indexOf(currentPage)
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + newPage
}
return false
}
val placeAtIndex = if (isReversed) currentIndex else currentIndex + 1
// It will enter a endless cycle of insert pages
if (isReversed && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
return false
}
// Same here it will enter a endless cycle of insert pages
if (items[placeAtIndex] is InsertPage) {
return false
}
items = items.toMutableList().apply { add(placeAtIndex, newPage) }
return true
}
fun cleanupPageSplit() {
items = items.filterNot { it is InsertPage }
}
/**
* Where the pager goes after the items change: to [position] to stay on the same item, then to [insertPage] at
* the end of the chapter, which it would skip otherwise.
*/
class Update(val position: Int?, val insertPage: InsertPage?)
}
@@ -2,12 +2,16 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import androidx.core.view.isVisible
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.processPagedImage
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -18,8 +22,11 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
/**
* View of the ViewPager that contains a page of a chapter.
@@ -45,7 +52,7 @@ class PagerPageHolder(
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderPageErrorView? = null
private var errorLayout: ReaderErrorBinding? = null
private val scope = MainScope()
@@ -55,7 +62,6 @@ class PagerPageHolder(
private var loadJob: Job? = null
init {
pageColorFilter = viewer.pageColorFilter
loadJob = scope.launch { loadPageAndProcessStatus() }
}
@@ -146,11 +152,9 @@ class PagerPageHolder(
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
// Splitting, merging and sampling the background decode the image, which is CPU work
val (source, isAnimated, background) = withContext(Dispatchers.Default) {
val source = viewer.simplePaged.processPagedImage(item, buffer, viewer is L2RPagerViewer) {
viewer.onPageSplit(item, it)
}
val source = process(item, buffer)
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
val background = if (!isAnimated && viewer.general.automaticBackground) {
val background = if (!isAnimated && viewer.config.automaticBackground) {
ImageUtil.chooseBackground(context, source.peek().inputStream())
} else {
null
@@ -162,11 +166,11 @@ class PagerPageHolder(
source,
isAnimated,
Config(
zoomDuration = viewer.general.doubleTapAnimDuration,
minimumScaleType = viewer.paged.page.imageScaleType.value,
cropBorders = viewer.paged.page.cropBorders,
zoomStartPosition = viewer.imageZoomType,
landscapeZoom = viewer.paged.page.landscapeZoom,
zoomDuration = viewer.config.doubleTapAnimDuration,
minimumScaleType = viewer.config.imageScaleType,
cropBorders = viewer.config.imageCropBorders,
zoomStartPosition = viewer.config.imageZoomType,
landscapeZoom = viewer.config.landscapeZoom,
),
)
if (!isAnimated) {
@@ -182,6 +186,63 @@ class PagerPageHolder(
}
}
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (!viewer.config.dualPageSplit) {
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageSource)
}
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageSource
}
onPageSplit(page)
return splitInHalf(imageSource)
}
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
var side = when {
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
viewer is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.LEFT
viewer !is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.RIGHT
else -> error("We should choose a side!")
}
if (viewer.config.dualPageInvert) {
side = when (side) {
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
}
}
return ImageUtil.splitInHalf(imageSource, side)
}
private fun onPageSplit(page: ReaderPage) {
val newPage = InsertPage(page)
viewer.onPageSplit(page, newPage)
}
/**
* Called when the page has an error.
*/
@@ -211,24 +272,41 @@ class PagerPageHolder(
viewer.activity.hideMenu()
}
private fun showErrorLayout(error: Throwable?) {
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
errorLayout = it
private fun showErrorLayout(error: Throwable?): ReaderErrorBinding {
if (errorLayout == null) {
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), this, true)
errorLayout?.actionRetry?.viewer = viewer
errorLayout?.actionRetry?.setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
}
errorLayout.bind(
page = page,
error = error,
sourceId = viewer.activity.viewModel.manga?.source,
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
)
val imageUrl = page.imageUrl
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
if (imageUrl != null) {
if (imageUrl.startsWith("http", true)) {
errorLayout?.actionOpenInWebView?.viewer = viewer
errorLayout?.actionOpenInWebView?.setOnClickListener {
val sourceId = viewer.activity.viewModel.manga?.source
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
context.startActivity(intent)
}
}
}
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
?: context.stringResource(MR.strings.decode_image_error)
errorLayout?.root?.isVisible = true
return errorLayout!!
}
/**
* Removes the decode error layout from the holder, if found.
*/
private fun removeErrorLayout() {
errorLayout?.let(::removeView)
errorLayout?.root?.isVisible = false
errorLayout = null
}
}
@@ -1,151 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import tachiyomi.core.common.util.system.logcat
/**
* The item a pager of [items] is on, shared by the view and Compose pagers. It tells [activity] of each new page,
* preloads the chapters around it, and gives [onPageHolderSelected] the page along with whether it was reached moving
* forward. Chapters set while the pager moves are held until it's idle and then given to [setChaptersNow].
*/
class PagerPageTracker(
private val activity: ReaderActivity,
private val items: PagerItems,
private val setChaptersNow: (ViewerChapters) -> Unit,
private val onPageHolderSelected: (page: ReaderPage, forward: Boolean) -> Unit,
) {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
var currentPage: Any? = null
private set
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersNow(viewerChapters)
awaitingIdleViewerChapters = null
if (viewerChapters.currChapter.pages?.size == 1) {
items.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
}
}
/**
* Sets the given [chapters] now if the pager is idle, otherwise once it becomes idle.
*/
fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersNow(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
}
/**
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
fun onPageChange(position: Int) {
val page = items.items.getOrNull(position)
if (page != null && currentPage != page) {
val allowPreload = checkAllowPreload(page as? ReaderPage)
val forward = when {
currentPage is ReaderPage && page is ReaderPage -> {
// if both pages have the same number, it's a split page with an InsertPage
if (page.number == (currentPage as ReaderPage).number) {
// the InsertPage is always the second in the reading direction
page is InsertPage
} else {
page.number > (currentPage as ReaderPage).number
}
}
currentPage is ChapterTransition.Prev && page is ReaderPage ->
false
else -> true
}
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going to next chapter from chapter transition
// 2. Going between pages of same chapter
// 3. Next chapter page
return when (page.chapter) {
(currentPage as? ChapterTransition.Next)?.to -> true
(currentPage as? ReaderPage)?.chapter -> true
items.nextTransition?.to -> true
else -> false
}
}
/**
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Notify holder of page change
onPageHolderSelected(page, forward)
// Skip preload on inserts it causes unwanted page jumping
if (page is InsertPage) {
return
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
items.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
/**
* Called when a [ChapterTransition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
}
@@ -3,12 +3,25 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatTextView
import com.google.android.material.progressindicator.CircularProgressIndicator
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
/**
* View of the ViewPager that contains a chapter transition.
@@ -20,12 +33,25 @@ class PagerTransitionHolder(
val transition: ChapterTransition,
) : LinearLayout(readerThemedContext), ViewPagerAdapter.PositionableView {
private val scope = MainScope()
private var stateJob: Job? = null
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item: Any
get() = transition
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
orientation = VERTICAL
gravity = Gravity.CENTER
}
init {
orientation = VERTICAL
gravity = Gravity.CENTER
@@ -34,16 +60,92 @@ class PagerTransitionHolder(
val transitionView = ReaderTransitionView(context)
addView(transitionView)
addView(pagesContainer)
transitionView.bind(
transition = transition,
downloadState = viewer.downloadManager.transitionDownloadState(
transition,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
),
onRetry = viewer.activity::requestPreloadChapter,
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
)
transition.to?.let(::observeStatus)
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stateJob?.cancel()
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter) {
stateJob?.cancel()
stateJob = scope.launch {
chapter.stateFlow
.collectLatest { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error)
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
// No additional view is added
}
}
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
}
val retryBtn = ReaderButton(context).apply {
viewer = this@PagerTransitionHolder.viewer
wrapContent()
text = context.stringResource(MR.strings.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
this@PagerTransitionHolder.viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
/**
* Extension method to set layout params to wrap content on this view.
*/
private fun View.wrapContent() {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}
}
@@ -1,17 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.graphics.ColorFilter
import android.view.ViewGroup
import android.graphics.PointF
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asAndroidColorFilter
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -22,14 +16,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.GeneralSettings
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
import eu.kanade.tachiyomi.ui.reader.viewer.PagedSettings
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.collectChanges
import eu.kanade.tachiyomi.ui.reader.viewer.mouseWheelPaging
import eu.kanade.tachiyomi.ui.reader.viewer.position
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import mihon.app.di.appGraph
@@ -44,6 +32,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
val graph by lazy { activity.appGraph }
val downloadManager by lazy { graph.downloadManager }
val readerPreferences by lazy { graph.readerPreferences }
private val scope = MainScope()
@@ -53,56 +42,55 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
*/
val pager = createPager()
private val settingsViewModel = activity.settingsViewModel
/**
* Settings every paged viewer uses, like scale mode on images or tap zones.
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
*/
val paged: PagedSettings
get() = settingsViewModel.paged.value
/**
* Settings only the pager viewers use, like splitting double pages.
*/
val simplePaged: SimplePagedSettings
get() = settingsViewModel.simplePaged.value
/**
* Settings shared by every viewer, like allow taps or page transitions.
*/
val general: GeneralSettings
get() = settingsViewModel.general.value
val imageZoomType: ZoomStartPosition
get() = paged.page.zoomStart.position(
auto = when (this) {
is L2RPagerViewer -> ZoomStartPosition.LEFT
is R2LPagerViewer -> ZoomStartPosition.RIGHT
else -> ZoomStartPosition.CENTER
},
)
private val pagerItems = PagerItems(isReversed = this is R2LPagerViewer)
val config = PagerConfig(this, scope, readerPreferences)
/**
* Adapter of the pager.
*/
private val adapter = PagerViewerAdapter(this, pagerItems)
private val adapter = PagerViewerAdapter(this)
private val tracker = PagerPageTracker(activity, pagerItems, ::setChaptersInternal) { page, forward ->
getPageHolder(page)?.onPageSelected(forward)
}
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
private var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersInternal(viewerChapters)
awaitingIdleViewerChapters = null
if (viewerChapters.currChapter.pages?.size == 1) {
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
}
}
private val pagerListener = object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
if (!activity.isScrollingThroughPages) {
activity.hideMenu()
}
tracker.onPageChange(position)
onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
tracker.isIdle = state == ViewPager.SCROLL_STATE_IDLE
isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
}
@@ -110,7 +98,6 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.isVisible = false // Don't layout the pager yet
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
pager.isFocusable = false
pager.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
pager.offscreenPageLimit = 1
pager.id = R.id.reader_pager
pager.adapter = adapter
@@ -120,14 +107,20 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.getLocationOnScreen(viewPosition)
val viewPositionRelativeToWindow = IntArray(2)
pager.getLocationInWindow(viewPositionRelativeToWindow)
val pos = Offset(
val pos = PointF(
(event.rawX - viewPosition[0] + viewPositionRelativeToWindow[0]) / pager.width,
(event.rawY - viewPosition[1] + viewPositionRelativeToWindow[1]) / pager.height,
)
activity.onViewerTap(pos)
when (config.navigator.getAction(pos)) {
NavigationRegion.MENU -> activity.toggleMenu()
NavigationRegion.NEXT -> moveToNext()
NavigationRegion.PREV -> moveToPrevious()
NavigationRegion.RIGHT -> moveRight()
NavigationRegion.LEFT -> moveLeft()
}
}
pager.longTapListener = f@{
if (activity.viewModel.state.value.menuVisible || general.longTapEnabled) {
if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) {
val item = adapter.items.getOrNull(pager.currentItem)
if (item is ReaderPage) {
activity.onPageLongTap(item)
@@ -137,23 +130,19 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
false
}
settingsViewModel.general.collectChanges(scope) { old, new ->
if (old != null && old.theme != new.theme) {
refreshAdapter()
}
}
settingsViewModel.simplePaged.collectChanges(scope) { old, new ->
if (old != null) {
refreshAdapter()
}
if (old?.dualPageSplit != new.dualPageSplit && !new.dualPageSplit) {
config.dualPageSplitChangedListener = { enabled ->
if (!enabled) {
cleanupPageSplit()
}
}
settingsViewModel.paged.collectChanges(scope) { old, new ->
if (old != null && old.page != new.page) {
refreshAdapter()
}
config.imagePropertyChangedListener = {
refreshAdapter()
}
config.navigationModeChangedListener = {
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
}
@@ -167,23 +156,11 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
*/
abstract fun createPager(): Pager
/** The page images' colours, for holders made later - see [LocalPageColorFilter]. */
var pageColorFilter: ColorFilter? = null
private set
@Composable
override fun Content(modifier: Modifier) {
val filter = LocalPageColorFilter.current
val pageFilter = remember(filter) { filter?.asAndroidColorFilter() }
SideEffect {
pageColorFilter = pageFilter
// The pager keeps every page it holds as a child.
pager.children.filterIsInstance<PagerPageHolder>().forEach { it.pageColorFilter = pageFilter }
}
AndroidView(
factory = { context -> DisallowInterceptBarrier(context).apply { addView(pager) } },
modifier = modifier.mouseWheelPaging(this),
)
/**
* Returns the view this viewer uses.
*/
override fun getView(): View {
return pager
}
/**
@@ -194,12 +171,105 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
.filterIsInstance(PagerPageHolder::class.java)
.firstOrNull { it.item == page }
/**
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
private fun onPageChange(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
val allowPreload = checkAllowPreload(page as? ReaderPage)
val forward = when {
currentPage is ReaderPage && page is ReaderPage -> {
// if both pages have the same number, it's a split page with an InsertPage
if (page.number == (currentPage as ReaderPage).number) {
// the InsertPage is always the second in the reading direction
page is InsertPage
} else {
page.number > (currentPage as ReaderPage).number
}
}
currentPage is ChapterTransition.Prev && page is ReaderPage ->
false
else -> true
}
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going to next chapter from chapter transition
// 2. Going between pages of same chapter
// 3. Next chapter page
return when (page.chapter) {
(currentPage as? ChapterTransition.Next)?.to -> true
(currentPage as? ReaderPage)?.chapter -> true
adapter.nextTransition?.to -> true
else -> false
}
}
/**
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Notify holder of page change
getPageHolder(page)?.onPageSelected(forward)
// Skip preload on inserts it causes unwanted page jumping
if (page is InsertPage) {
return
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
/**
* Called when a [ChapterTransition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
/**
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setChapters(chapters: ViewerChapters) {
tracker.setChapters(chapters)
if (isIdle) {
setChaptersInternal(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
}
/**
@@ -209,7 +279,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
// Remove listener so the change in item doesn't trigger it
pager.removeOnPageChangeListener(pagerListener)
val forceTransition = general.alwaysShowChapterTransition ||
val forceTransition = config.alwaysShowChapterTransition ||
adapter.items.getOrNull(pager.currentItem) is ChapterTransition
adapter.setChapters(chapters, forceTransition)
@@ -223,7 +293,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.addOnPageChangeListener(pagerListener)
// Manually call onPageChange to update the UI
tracker.onPageChange(pager.currentItem)
onPageChange(pager.currentItem)
}
/**
@@ -236,7 +306,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.setCurrentItem(position, true)
// manually call onPageChange since ViewPager listener is not triggered in this case
if (currentPosition == position) {
tracker.onPageChange(position)
onPageChange(position)
}
} else {
logcat { "Page $page not found in adapter" }
@@ -244,24 +314,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
}
/**
* Whether pages run right to left, so moving forward goes left.
* Moves to the next page.
*/
protected open val isReversed = false
open fun moveToNext() {
moveRight()
}
override fun moveForward() = if (isReversed) moveLeft() else moveRight()
override fun moveBackward() = if (isReversed) moveRight() else moveLeft()
/**
* Moves to the previous page.
*/
open fun moveToPrevious() {
moveLeft()
}
/**
* Moves to the page at the right.
*/
private fun moveRight() {
protected open fun moveRight() {
if (pager.currentItem != adapter.count - 1) {
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && paged.navigateToPan && holder.canPanRight()) {
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && config.navigateToPan && holder.canPanRight()) {
holder.panRight()
} else {
pager.setCurrentItem(pager.currentItem + 1, general.usePageTransitions)
pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions)
}
}
}
@@ -269,17 +344,31 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
/**
* Moves to the page at the left.
*/
private fun moveLeft() {
protected open fun moveLeft() {
if (pager.currentItem != 0) {
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && paged.navigateToPan && holder.canPanLeft()) {
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && config.navigateToPan && holder.canPanLeft()) {
holder.panLeft()
} else {
pager.setCurrentItem(pager.currentItem - 1, general.usePageTransitions)
pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions)
}
}
}
/**
* Moves to the page at the top (or previous).
*/
protected open fun moveUp() {
moveToPrevious()
}
/**
* Moves to the page at the bottom (or next).
*/
protected open fun moveDown() {
moveToNext()
}
/**
* Resets the adapter in order to recreate all the views. Used when a image configuration is
* changed.
@@ -291,6 +380,69 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.setCurrentItem(currentItem, false)
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
val ctrlPressed = event.metaState.and(KeyEvent.META_CTRL_ON) > 0
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveDown() else moveUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveUp() else moveDown()
}
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (isUp) {
if (ctrlPressed) moveToNext() else moveRight()
}
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
if (isUp) {
if (ctrlPressed) moveToPrevious() else moveLeft()
}
}
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
when (event.action) {
MotionEvent.ACTION_SCROLL -> {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
moveDown()
} else {
moveUp()
}
return true
}
}
}
return false
}
fun onPageSplit(currentPage: ReaderPage, newPage: InsertPage) {
activity.runOnUiThread {
// Need to insert on UI thread else images will go blank
@@ -302,12 +454,3 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
adapter.cleanupPageSplit()
}
}
/**
* Keeps [requestDisallowInterceptTouchEvent] from reaching Compose. Its interop filter cancels the
* gesture when a child re-allows intercepting mid-drag, which stops a zoomed page from handing the
* drag to the pager at its edge.
*/
private class DisallowInterceptBarrier(context: Context) : FrameLayout(context) {
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit
}
@@ -4,8 +4,10 @@ import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import tachiyomi.core.common.util.system.logcat
@@ -13,16 +15,23 @@ import tachiyomi.core.common.util.system.logcat
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class PagerViewerAdapter(
private val viewer: PagerViewer,
private val pagerItems: PagerItems,
) : ViewPagerAdapter() {
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* List of currently set items.
*/
val items: List<Any>
get() = pagerItems.items
var items: MutableList<Any> = mutableListOf()
private set
/**
* Holds preprocessed items so they don't get removed when changing chapter
*/
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
var currentChapter: ReaderChapter? = null
/**
* Context that has been wrapped to use the correct theme values based on the
@@ -36,13 +45,85 @@ class PagerViewerAdapter(
* has R2L direction.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val update = pagerItems.setChapters(chapters, forceTransition, viewer.pager.currentItem)
val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition
chapters.prevChapter?.pages?.let(newItems::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
var insertPageLastPage: InsertPage? = null
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
val pages = currPages.toMutableList()
val lastPage = pages.last()
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.forEach { page ->
if (lastPage.index == page.index) {
insertPageLastPage = page
}
pages.add(page.index + 1, page)
}
newItems.addAll(pages)
}
currentChapter = chapters.currChapter
// Add next chapter transition and pages.
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
.also {
if (
nextHasMissingChapters ||
forceTransition ||
chapters.nextChapter?.state !is ReaderChapter.State.Loaded
) {
newItems.add(it)
}
}
chapters.nextChapter?.pages?.let(newItems::addAll)
val currentItem = items.getOrNull(viewer.pager.currentItem)
// Resets double-page splits, else insert pages get misplaced
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
items = newItems
notifyDataSetChanged()
update.position?.let { viewer.pager.setCurrentItem(it, false) }
// The pager only follows the pages it has created, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val currentPosition = items.indexOf(currentItem)
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
viewer.pager.setCurrentItem(currentPosition, false)
}
// Will skip insert page otherwise
update.insertPage?.let(viewer::moveToPage)
if (insertPageLastPage != null) {
viewer.moveToPage(insertPageLastPage)
}
}
/**
@@ -79,13 +160,44 @@ class PagerViewerAdapter(
}
fun onPageSplit(currentPage: Any?, newPage: InsertPage) {
if (pagerItems.onPageSplit(currentPage, newPage)) {
notifyDataSetChanged()
if (currentPage !is ReaderPage) return
val currentIndex = items.indexOf(currentPage)
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + newPage
}
return
}
val placeAtIndex = when (viewer) {
is L2RPagerViewer,
is VerticalPagerViewer,
-> currentIndex + 1
else -> currentIndex
}
// It will enter a endless cycle of insert pages
if (viewer is R2LPagerViewer && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
return
}
// Same here it will enter a endless cycle of insert pages
if (items[placeAtIndex] is InsertPage) {
return
}
items.add(placeAtIndex, newPage)
notifyDataSetChanged()
}
fun cleanupPageSplit() {
pagerItems.cleanupPageSplit()
val insertPages = items.filterIsInstance<InsertPage>()
items.removeAll(insertPages)
notifyDataSetChanged()
}
@@ -25,7 +25,19 @@ class R2LPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
return Pager(activity)
}
override val isReversed = true
/**
* Moves to the next page. On a R2L pager the next page is the one at the left.
*/
override fun moveToNext() {
moveLeft()
}
/**
* Moves to the previous page. On a R2L pager the previous page is the one at the right.
*/
override fun moveToPrevious() {
moveRight()
}
}
/**
@@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
/**
* Settings only the pager viewers use, all of them part of what the pages are made with.
*/
data class SimplePagedSettings(
override val dualPageSplit: Boolean,
override val dualPageInvert: Boolean,
override val dualPageRotateToFit: Boolean,
override val dualPageRotateToFitInvert: Boolean,
) : DualPageSettings
@@ -1,208 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import ca.mpreg.webgpuviewer.viewer.ComposePageSize
import ca.mpreg.webgpuviewer.viewer.ImagePage
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.reader.ReaderError
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderThemedContent
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.presentation.core.components.CombinedCircularProgressIndicator
import tachiyomi.source.local.isLocal
/** The side padding `PagerTransitionHolder` gave the same transition view. */
private const val TRANSITION_SIDE_PADDING = 64
/** The padding `WebtoonTransitionHolder` gave it, which took only the height it needed. */
private const val STRIP_TRANSITION_SIDE_PADDING = 32
private const val STRIP_TRANSITION_VERTICAL_PADDING = 128
/** How much of the viewport `WebtoonPageHolder` gave its error layout. */
private const val STRIP_ERROR_HEIGHT_FRACTION = 0.8f
/** The top margin `WebtoonPageHolder` gave its spinner, as a fraction of the viewport. */
private const val STRIP_PROGRESS_TOP_FRACTION = 0.25f
/**
* The reader's own chapter and page chrome, as composables, in the reader's theme.
*
* Built on the reader background, which is fixed for the page's life: a theme change rebuilds every
* page (see [WebGpuViewer]'s image property listener).
*/
abstract class ReaderComposePage(
protected val viewer: WebGpuViewer,
size: ComposePageSize = ComposePageSize.Auto,
) : ImagePage.Compose(size, viewer.readerBackgroundColor()) {
/**
* The theme goes on here because the library's page knows nothing of the app's colours.
*/
@Composable
final override fun BoxScope.Content() {
ReaderThemedContent(viewer.readerThemedContext) { PageContent() }
}
@Composable
protected abstract fun PageContent()
}
/**
* A page that failed to load or decode, with the pager's error layout: the message, Retry, and
* Open in WebView for an image that came off the web.
*/
class ErrorPage internal constructor(
viewer: WebGpuViewer,
private val viewerPage: WebGpuViewer.ViewerReaderPage?,
private val error: Throwable?,
) : ReaderComposePage(
viewer,
// WebtoonPageHolder gave its error layout less than a screen, so the page below shows through.
if (viewer is WebGpuViewerContinuous) ComposePageSize.Wrap else ComposePageSize.Auto,
) {
@Composable
override fun PageContent() {
val context = LocalContext.current
// As the pager's own error layout: only offered for an image that came off the web.
val imageUrl = viewerPage?.page?.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
val strip = if (viewer is WebGpuViewerContinuous) {
Modifier.fillMaxHeight(STRIP_ERROR_HEIGHT_FRACTION).wrapContentHeight()
} else {
Modifier
}
ReaderError(
modifier = strip,
message = with(context) { error?.formattedMessage },
onRetry = { viewerPage?.let { viewer.retryLoad(it) } },
onOpenInWebView = imageUrl?.let {
{
val sourceId = viewer.activity.viewModel.manga?.source
context.startActivity(WebViewActivity.newIntent(context, it, sourceId))
}
},
)
}
}
/** A page still downloading: the reader's progress ring. */
class ProgressPage(viewer: WebGpuViewer) : ReaderComposePage(viewer) {
/**
* 0 to 1. On the page, not in the composition, which is dropped when the page goes out of
* reach. Written from the loader's collector; snapshot state takes writes from any thread.
*/
var progress: Float
get() = progressState
set(value) {
progressState = value.fastCoerceIn(0f, 1f)
}
private var progressState by mutableFloatStateOf(0f)
@Composable
override fun PageContent() {
if (viewer !is WebGpuViewerContinuous) {
CombinedCircularProgressIndicator(progress = { progress })
return
}
// WebtoonPageHolder centred its spinner below a quarter-height top margin, which puts it
// at five eighths of the slot rather than halfway down.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = maxHeight * STRIP_PROGRESS_TOP_FRACTION),
contentAlignment = Alignment.Center,
) {
CombinedCircularProgressIndicator(progress = { progress })
}
}
}
}
/** The page between two chapters, or before the first and after the last. */
class TransitionPage(
viewer: WebGpuViewer,
val prevChapter: ReaderChapter?,
val nextChapter: ReaderChapter?,
) : ReaderComposePage(viewer, ComposePageSize.Wrap) {
/** As the pager and webtoon adapters framed it: leaving [prevChapter] for [nextChapter]. */
private val transition: ChapterTransition? = when {
prevChapter != null -> ChapterTransition.Next(prevChapter, nextChapter)
nextChapter != null -> ChapterTransition.Prev(nextChapter, null)
else -> null
}
@Composable
override fun PageContent() {
val transition = transition ?: return
val goingToChapterDownloaded by produceState(false, transition) {
value = withContext(Dispatchers.IO) { isDownloaded(transition) }
}
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
// The padding the holder each viewer used to inflate this into gave it.
val padding = if (viewer is WebGpuViewerContinuous) {
Modifier.padding(
horizontal = STRIP_TRANSITION_SIDE_PADDING.dp,
vertical = STRIP_TRANSITION_VERTICAL_PADDING.dp,
)
} else {
Modifier.padding(horizontal = TRANSITION_SIDE_PADDING.dp)
}
Box(modifier = padding.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
ChapterTransition(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = goingToChapterDownloaded,
)
}
}
}
/** `ReaderTransitionView.bind`'s answer, which reads the disk. */
private suspend fun isDownloaded(transition: ChapterTransition): Boolean {
val manga = viewer.activity.viewModel.manga ?: return false
if (manga.isLocal()) return true
val chapter = transition.to?.chapter ?: return false
val source = viewer.activity.viewModel.source ?: return false
return viewer.downloadManager.isChapterDownloadedOnDisk(
chapterName = chapter.name,
chapterScanlator = chapter.scanlator,
chapterUrl = chapter.url,
mangaTitle = manga.title,
source = source,
)
}
}
@@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
/**
* Settings only the long strip WebGPU viewer uses.
*/
data class WebGpuLongStripSettings(
val page: Page,
val common: WebGpuSettings,
) {
/**
* What the pages are made with, so a change remakes them.
*/
data class Page(
val continuousMinWidth: Int,
val continuousGap: Int,
)
}
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
/**
* Settings only the paged WebGPU viewers use.
*/
data class WebGpuPagedSettings(
val page: Page,
val common: WebGpuSettings,
) {
/**
* What the pages are made with, so a change remakes them.
*/
data class Page(
val dualPageView: ReaderPreferences.DualPageView,
val transitionAnimation: ReaderPreferences.TransitionAnimation,
val transitionAnimationDual: ReaderPreferences.TransitionAnimation,
val cutoutMode: ReaderPreferences.CutoutMode,
val cutoutModeDual: ReaderPreferences.CutoutMode,
)
}
@@ -1,10 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
/**
* Settings both WebGPU viewers use, kept in each of their own settings.
*/
data class WebGpuSettings(
val doubleTapGesturesDisabled: Boolean,
val imageFilters: List<WebGpuShader>,
val tileFilters: List<WebGpuShader>,
)
@@ -1,113 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import ca.mpreg.webgpuviewer.renderer.ImageFilterWgsl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import tachiyomi.core.common.preference.Preference
import java.io.File
import java.util.UUID
/** A user's shader: [file] in [WebGpuShaders.dir], shown as [name]. */
data class WebGpuShader(val file: String, val name: String)
/** Picked shaders are copied in, so they outlive the picker's URI permission. */
object WebGpuShaders {
private const val MAX_BYTES = 1 shl 20
/** What [import] names files; anything else in a preference is not ours to read or delete. */
private val FILE = Regex("""[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.wgsl""")
/** Held while files and the preferences naming them change together. */
private val lock = Any()
private fun dir(context: Context) = File(context.filesDir, "webgpu_shaders")
fun serialize(shaders: List<WebGpuShader>): String =
shaders.joinToString("\n") { "${it.file}\t${it.name}" }
fun deserialize(text: String): List<WebGpuShader> = text.lineSequence()
.mapNotNull { line ->
val tab = line.indexOf('\t')
if (tab <= 0) return@mapNotNull null
val file = line.substring(0, tab)
if (FILE.matches(file)) WebGpuShader(file, line.substring(tab + 1)) else null
}
.distinctBy { it.file }
.toList()
/**
* Copies [uri] in and appends it to [pref]. Throws [IllegalArgumentException], with the reason, if
* it is not a filter this device runs.
*/
suspend fun import(context: Context, uri: Uri, pref: Preference<List<WebGpuShader>>) =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
} ?: uri.lastPathSegment ?: "shader"
val source = resolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(MAX_BYTES + 1)
var size = 0
while (size < buffer.size) {
val read = input.read(buffer, size, buffer.size - size)
if (read < 0) break
size += read
}
require(size <= MAX_BYTES) { "$name is over ${MAX_BYTES shr 20} MB" }
buffer.decodeToString(0, size)
} ?: throw IllegalArgumentException("cannot open $name")
ImageFilterWgsl(source, name).validate()
val shader = WebGpuShader("${UUID.randomUUID()}.wgsl", name.replace(Regex("""[\t\r\n]"""), " "))
// Past validation, the copy and its entry land together or not at all.
withContext(NonCancellable) {
synchronized(lock) {
val file = File(dir(context).apply { mkdirs() }, shader.file)
try {
file.writeText(source)
pref.set(pref.get() + shader)
} catch (e: Exception) {
file.delete()
throw e
}
}
}
}
fun remove(context: Context, pref: Preference<List<WebGpuShader>>, shader: WebGpuShader) {
synchronized(lock) {
pref.set(pref.get().filter { it.file != shader.file })
File(dir(context), shader.file).delete()
}
}
/**
* Deletes copies neither of [prefs] names, and entries whose copy is gone - left by a restored
* backup, or a crash between the two.
*/
suspend fun prune(context: Context, prefs: List<Preference<List<WebGpuShader>>>) =
withContext(Dispatchers.IO) {
synchronized(lock) {
val dir = dir(context)
val present = dir.list()?.toSet().orEmpty()
val named = HashSet<String>()
for (pref in prefs) {
val shaders = pref.get()
val kept = shaders.filter { it.file in present }
if (kept.size != shaders.size) pref.set(kept)
kept.mapTo(named) { it.file }
}
for (file in present) {
if (file !in named) File(dir, file).delete()
}
}
}
/** Throws if the file is gone or no longer parses. */
fun load(context: Context, shader: WebGpuShader): ImageFilterWgsl =
ImageFilterWgsl(File(dir(context), shader.file).readText(), shader.name)
}
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ca.mpreg.webgpuviewer.viewer.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuous
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.mouseWheelPaging
import kotlinx.coroutines.launch
import kotlin.math.max
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
WebGpuViewer(activity, isReversed = false, isVertical = true, viewerState = ImageViewerContinuousState()) {
override val isContinuous: Boolean get() = true
// How many pages the viewport shows depends on the zoom, and a page on screen has to be
// decoded rather than merely reserved - so the window follows what the last frame reached.
override val preloadAhead get() = max(3, state.pagesBelow)
override val preloadBehind get() = max(2, state.pagesAbove)
override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
private val state get() = viewerState as ImageViewerContinuousState
@Composable
override fun Content(modifier: Modifier) {
ImageViewerContinuous(modifier = modifier.mouseWheelPaging(this), state = state)
}
init {
state.backgroundColor = readerBackgroundColor()
state.onViewport = { readThrough ->
val pageChanged = readThrough !== lastReadThrough
if (pageChanged) {
lastReadThrough = readThrough
readThrough?.let { select(it) }
}
val page0 = state.getPage(0)
val edge = (page0 is TransitionPage && page0.prevChapter == null) ||
(readThrough is TransitionPage && readThrough.nextChapter == null)
val first = wasAtEdge == null
val edgeChanged = edge != wasAtEdge
wasAtEdge = edge
if (edge) {
if (edgeChanged) scope.launch { activity.showMenu() }
} else if (!first && (edgeChanged || pageChanged) && !activity.isScrollingThroughPages) {
scope.launch { activity.hideMenu() }
}
}
}
/** Null until the first frame, so opening mid-document neither shows nor hides. */
@Volatile
private var wasAtEdge: Boolean? = null
@Volatile
private var lastReadThrough: ImagePage? = null
/**
* Mark [imagePage] read - its bottom has cleared the viewport, which is later than
* onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to
* reveal the page above would otherwise walk progress back past pages still on screen.
*/
private fun select(imagePage: ImagePage) {
val page = readerPageFor(imagePage) ?: return
scope.launch { activity.onPageSelected(page) }
}
private fun readerPageFor(imagePage: ImagePage): ReaderPage? {
// Off the page itself: a transition page is never currentPage (the scroll walk stops
// before it), so it is the first thing a preload evicts.
if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull()
// Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted
// means scrolled well clear of, so there is no progress left to report.
return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page
}
override fun destroy() {
state.onViewport = null
lastReadThrough = null
wasAtEdge = null
super.destroy()
}
private fun scrollByHalfPage(direction: Int) {
state.animateScroll(direction * state.height / 2f)
}
override fun moveRight() = scrollByHalfPage(1)
override fun moveLeft() = scrollByHalfPage(-1)
override fun moveToPage(page: ReaderPage) {
super.moveToPage(page)
wasAtEdge = null
lastReadThrough = null
// Also for a jump to the page already showing, which turns nothing to slide in.
state.resetScroll()
}
override fun animateTurn(direction: Int, fromSpread: ImagePage) {
state.animateSlideIn(direction)
}
}
@@ -1,366 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.content.ActivityNotFoundException
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.presentation.reader.settings.TapZonesItems
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Add
import mihon.icons.materialsymbols.rounded.Delete
import mihon.icons.materialsymbols.rounded.DragHandle
import sh.calvin.reorderable.ReorderableColumn
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.SliderItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import java.text.NumberFormat
@Composable
internal fun WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.pref_category_for_this_series)
val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) {
ReadingMode.entries.map {
FilterChip(
selected = it == readingMode,
onClick = { viewModel.setReadingMode(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
val resolved by viewModel.readingMode.collectAsState()
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
SettingsChipRow(MR.strings.pref_dual_page_view) {
ReaderPreferences.DualPageView.entries.map {
FilterChip(
selected = it == dualPageView,
onClick = { viewModel.preferences.dualPageView.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
}
if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
val numberFormat = remember { NumberFormat.getPercentInstance() }
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
SliderItem(
value = continuousMinWidth,
valueRange = ReaderPreferences.let { 1..100 },
label = stringResource(MR.strings.pref_continuous_minwidth),
valueString = numberFormat.format(continuousMinWidth / 100f),
onChange = {
viewModel.preferences.continuousMinWidth.set(it)
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
if (resolved == ReadingMode.CONTINUOUS_VERTICAL) {
val continuousGap by viewModel.preferences.continuousGap.collectAsState()
SliderItem(
value = continuousGap,
valueRange = ReaderPreferences.let { 1..100 },
label = stringResource(MR.strings.pref_continuous_gap),
valueString = numberFormat.format(continuousGap / 100f),
onChange = {
viewModel.preferences.continuousGap.set(it)
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
}
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
pref = viewModel.preferences.webtoonDisableZoomOut,
)
}
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
SettingsChipRow(MR.strings.rotation_type) {
ReaderOrientation.entries.forEach {
FilterChip(
selected = it == orientation,
onClick = { viewModel.setOrientation(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
HeadingItem(MR.strings.webgpu_viewer)
// As the viewer decides it, with the activity's window in place of the viewer's size. Not the sheet's own window.
val configuration = LocalConfiguration.current
val isDual = resolved.type is ReadingMode.ViewerType.Pager &&
when (dualPageView) {
ReaderPreferences.DualPageView.NEVER -> false
ReaderPreferences.DualPageView.ALWAYS -> true
ReaderPreferences.DualPageView.WIDE -> configuration.screenWidthDp > configuration.screenHeightDp
}
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState()
TapZonesItems(
selected = navigationModePager,
onSelect = viewModel.preferences.navigationModePager::set,
invertMode = pagerNavInverted,
onSelectInvertMode = viewModel.preferences.pagerNavInverted::set,
)
if (isDual) {
val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState()
SettingsChipRow(MR.strings.pref_transition_animation_dual) {
(
ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT -
ReaderPreferences.TransitionAnimation.FLIP_RIGHT
).forEach {
FilterChip(
selected = it == transitionAnimation,
onClick = { viewModel.preferences.transitionAnimationDual.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState()
SettingsChipRow(MR.strings.pref_cutout_mode_dual) {
ReaderPreferences.CutoutMode.entries.forEach {
FilterChip(
selected = it == cutoutMode,
onClick = { viewModel.preferences.cutoutModeDual.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
return
}
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
SettingsChipRow(MR.strings.pref_image_scale_type) {
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
FilterChip(
selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it,
onClick = {
viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1)
},
label = { Text(stringResource(it)) },
)
}
}
val zoomStart by viewModel.preferences.zoomStart.collectAsState()
SettingsChipRow(MR.strings.pref_zoom_start) {
ReaderPreferences.ZoomStart.mapIndexed { index, it ->
FilterChip(
selected = zoomStart == index + 1,
onClick = { viewModel.preferences.zoomStart.set(index + 1) },
label = { Text(stringResource(it)) },
)
}
}
CheckboxItem(
label = stringResource(MR.strings.pref_crop_borders),
pref = viewModel.preferences.cropBorders,
)
CheckboxItem(
label = stringResource(MR.strings.pref_landscape_zoom),
pref = viewModel.preferences.landscapeZoom,
)
CheckboxItem(
label = stringResource(MR.strings.pref_navigate_pan),
pref = viewModel.preferences.navigateToPan,
)
CheckboxItem(
label = stringResource(MR.strings.pref_disable_double_tap_gestures),
pref = viewModel.preferences.disableDoubleTapGestures,
)
val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState()
SettingsChipRow(MR.strings.pref_transition_animation) {
ReaderPreferences.TransitionAnimation.entries.forEach {
FilterChip(
selected = it == transitionAnimation,
onClick = { viewModel.preferences.transitionAnimation.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
val cutoutMode by viewModel.preferences.cutoutMode.collectAsState()
SettingsChipRow(MR.strings.pref_cutout_mode) {
ReaderPreferences.CutoutMode.entries.forEach {
FilterChip(
selected = it == cutoutMode,
onClick = { viewModel.preferences.cutoutMode.set(it) },
label = { Text(stringResource(it.titleRes)) },
)
}
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
WebGpuShaders.prune(context, listOf(viewModel.preferences.imageFilters, viewModel.preferences.tileFilters))
}
ShaderList(MR.strings.pref_image_filters, viewModel.preferences.imageFilters)
ShaderList(MR.strings.pref_tile_filters, viewModel.preferences.tileFilters)
}
@Composable
private fun ShaderList(labelRes: StringResource, pref: Preference<List<WebGpuShader>>) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val filters by pref.collectAsState()
var error by remember { mutableStateOf<String?>(null) }
val pickShader = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
try {
WebGpuShaders.import(context, uri, pref)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.WARN, e)
error = e.message ?: e.toString()
}
}
}
error?.let { message ->
AlertDialog(
onDismissRequest = { error = null },
confirmButton = {
TextButton(onClick = { error = null }) {
Text(stringResource(MR.strings.action_ok))
}
},
title = { Text(stringResource(MR.strings.image_filter_invalid)) },
text = {
SelectionContainer(Modifier.verticalScroll(rememberScrollState())) {
Text(
text = message,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
}
},
)
}
HeadingItem(labelRes)
ReorderableColumn(
list = filters,
onSettle = { from, to ->
// The indices are into the list as drawn, which the preference may have moved past.
val moved = filters.toMutableList().apply { add(to, removeAt(from)) }
val current = pref.get()
val files = current.mapTo(HashSet()) { it.file }
val kept = moved.filter { it.file in files }
val keptFiles = kept.mapTo(HashSet()) { it.file }
pref.set(kept + current.filter { it.file !in keptFiles })
},
) { _, shader, _ ->
key(shader.file) {
ReorderableItem {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = SettingsItemsPaddings.Horizontal, end = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MaterialSymbols.Rounded.DragHandle,
contentDescription = null,
modifier = Modifier.draggableHandle(),
)
Text(
text = shader.name,
modifier = Modifier
.weight(1f)
.padding(horizontal = MaterialTheme.padding.medium),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(
onClick = { WebGpuShaders.remove(context, pref, shader) },
) {
Icon(
imageVector = MaterialSymbols.Rounded.Delete,
contentDescription = stringResource(MR.strings.action_delete),
)
}
}
}
}
}
TextButton(
onClick = {
try {
pickShader.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
error = e.message ?: e.toString()
}
},
modifier = Modifier.padding(horizontal = SettingsItemsPaddings.Horizontal - MaterialTheme.padding.small),
) {
Icon(imageVector = MaterialSymbols.Rounded.Add, contentDescription = null)
Text(
text = stringResource(MR.strings.action_add_shader),
modifier = Modifier.padding(start = MaterialTheme.padding.small),
)
}
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationSettings
/**
* Settings only the webtoon viewers use.
*/
data class SimpleLongStripSettings(
val page: Page,
val doubleTapZoom: Boolean,
val navigation: NavigationSettings,
) {
/**
* What the pages are made with, so a change remakes them.
*/
data class Page(
val sidePadding: Int,
override val dualPageSplit: Boolean,
override val dualPageInvert: Boolean,
override val dualPageRotateToFit: Boolean,
override val dualPageRotateToFitInvert: Boolean,
) : DualPageSettings
}
@@ -5,24 +5,25 @@ import android.widget.LinearLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
/**
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class WebtoonAdapter(
val viewer: WebtoonViewer,
private val webtoonItems: WebtoonItems,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/**
* List of currently set items.
*/
val items: List<Any>
get() = webtoonItems.items
var items: List<Any> = emptyList()
private set
var currentChapter: ReaderChapter? = null
/**
* Context that has been wrapped to use the correct theme values based on the
@@ -35,9 +36,42 @@ class WebtoonAdapter(
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val oldItems = items
webtoonItems.setChapters(chapters, forceTransition)
DiffUtil.calculateDiff(Callback(oldItems, items)).dispatchUpdatesTo(this)
val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition.
chapters.prevChapter?.pages?.let(newItems::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
currentChapter = chapters.currChapter
// Add next chapter transition and pages.
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
chapters.nextChapter?.pages?.let(newItems::addAll)
updateItems(newItems)
}
private fun updateItems(newItems: List<Any>) {
val result = DiffUtil.calculateDiff(Callback(items, newItems))
items = newItems
result.dispatchUpdatesTo(this)
}
fun refresh() {
@@ -69,7 +103,6 @@ class WebtoonAdapter(
return when (viewType) {
PAGE_VIEW -> {
val view = ReaderPageImageView(readerThemedContext, isWebtoon = true)
viewer.onPageViewCreated(view)
WebtoonPageHolder(view, viewer)
}
TRANSITION_VIEW -> {
@@ -0,0 +1,120 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Configuration used by webtoon viewers.
*/
class WebtoonConfig(
scope: CoroutineScope,
readerPreferences: ReaderPreferences,
) : ViewerConfig(readerPreferences, scope) {
var themeChangedListener: (() -> Unit)? = null
var imageCropBorders = false
private set
var zoomOutDisabled = false
private set
var zoomPropertyChangedListener: ((Boolean) -> Unit)? = null
var sidePadding = 0
private set
var doubleTapZoom = true
private set
var doubleTapZoomChangedListener: ((Boolean) -> Unit)? = null
val theme = readerPreferences.readerTheme.get()
init {
readerPreferences.cropBordersWebtoon
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.webtoonSidePadding
.register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigationModeWebtoon
.register({ navigationMode = it }, { updateNavigation(it) })
readerPreferences.webtoonNavInverted
.register({ tappingInverted = it }, { navigator.invertMode = it })
readerPreferences.webtoonNavInverted.changes()
.drop(1)
.onEach { navigationModeChangedListener?.invoke() }
.launchIn(scope)
readerPreferences.dualPageSplitWebtoon
.register({ dualPageSplit = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.dualPageInvertWebtoon
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.dualPageRotateToFitWebtoon
.register(
{ dualPageRotateToFit = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.dualPageRotateToFitInvertWebtoon
.register(
{ dualPageRotateToFitInvert = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.webtoonDisableZoomOut
.register(
{ zoomOutDisabled = it },
{ zoomPropertyChangedListener?.invoke(it) },
)
readerPreferences.webtoonDoubleTapZoomEnabled
.register(
{ doubleTapZoom = it },
{ doubleTapZoomChangedListener?.invoke(it) },
)
readerPreferences.readerTheme.changes()
.drop(1)
.distinctUntilChanged()
.onEach { themeChangedListener?.invoke() }
.launchIn(scope)
}
override var navigator: ViewerNavigation = defaultNavigation()
set(value) {
field = value.also { it.invertMode = tappingInverted }
}
override fun defaultNavigation(): ViewerNavigation {
return LNavigation()
}
override fun updateNavigation(navigationMode: Int) {
this.navigator = when (navigationMode) {
0 -> defaultNavigation()
1 -> LNavigation()
2 -> KindlishNavigation()
3 -> EdgeNavigation()
4 -> RightAndLeftNavigation()
5 -> DisabledNavigation()
else -> defaultNavigation()
}
navigationModeChangedListener?.invoke()
}
}
@@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
/**
* Items of a webtoon viewer, where [ViewerChapters] updates are posted.
*/
class WebtoonItems {
/**
* List of currently set items.
*/
var items: List<Any> by mutableStateOf(emptyList())
private set
var currentChapter: ReaderChapter? = null
private set
/**
* Updates the items with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
currentChapter = chapters.currChapter
items = viewerItems(chapters, forceTransition)
}
}
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
@@ -8,12 +9,13 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.processStripImage
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -24,8 +26,11 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
/**
* Holder of the webtoon reader for a single page of a chapter.
@@ -53,7 +58,7 @@ class WebtoonPageHolder(
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderPageErrorView? = null
private var errorLayout: ReaderErrorBinding? = null
/**
* Getter to retrieve the height of the recycler view.
@@ -86,7 +91,6 @@ class WebtoonPageHolder(
*/
fun bind(page: ReaderPage) {
this.page = page
frame.pageColorFilter = viewer.pageColorFilter
loadJob?.cancel()
loadJob = scope.launch { loadPageAndProcessStatus() }
refreshLayoutParams()
@@ -186,7 +190,7 @@ class WebtoonPageHolder(
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
// Splitting and merging decode the image, which is CPU work
val (source, isAnimated) = withContext(Dispatchers.Default) {
val source = viewer.simpleLongStrip.page.processStripImage(buffer)
val source = process(buffer)
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
Pair(source, isAnimated)
}
@@ -195,9 +199,9 @@ class WebtoonPageHolder(
source,
isAnimated,
ReaderPageImageView.Config(
zoomDuration = viewer.general.doubleTapAnimDuration,
zoomDuration = viewer.config.doubleTapAnimDuration,
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH,
cropBorders = viewer.longStrip.cropBorders,
cropBorders = viewer.config.imageCropBorders,
),
)
removeErrorLayout()
@@ -210,6 +214,32 @@ class WebtoonPageHolder(
}
}
private fun process(imageSource: BufferedSource): BufferedSource {
if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageSource
}
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
/**
* Called when the page has an error.
*/
@@ -243,15 +273,34 @@ class WebtoonPageHolder(
}
/**
* Shows the error of the page.
* Initializes a button to retry pages.
*/
private fun initErrorLayout(error: Throwable?) {
val page = page ?: return
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
frame.addView(it, FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt()))
errorLayout = it
private fun initErrorLayout(error: Throwable?): ReaderErrorBinding {
if (errorLayout == null) {
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), frame, true)
errorLayout?.root?.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt())
errorLayout?.actionRetry?.setOnClickListener {
page?.let { it.chapter.pageLoader?.retryPage(it) }
}
}
errorLayout.bind(page = page, error = error, sourceId = viewer.activity.viewModel.manga?.source)
val imageUrl = page?.imageUrl
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
if (imageUrl != null) {
if (imageUrl.startsWith("http", true)) {
errorLayout?.actionOpenInWebView?.setOnClickListener {
val sourceId = viewer.activity.viewModel.manga?.source
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
context.startActivity(intent)
}
}
}
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
?: context.stringResource(MR.strings.decode_image_error)
return errorLayout!!
}
/**
@@ -259,7 +308,7 @@ class WebtoonPageHolder(
*/
private fun removeErrorLayout() {
errorLayout?.let {
frame.removeView(it)
frame.removeView(it.root)
errorLayout = null
}
}
@@ -1,121 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import tachiyomi.core.common.util.system.logcat
import kotlin.math.abs
/**
* The item a webtoon viewer of [items] is on, shared by the view and Compose viewers. It tells [activity] of each new
* page and preloads the chapters around it. A scroll further than [hideThreshold] hides the menu.
*/
class WebtoonPageTracker(
private val activity: ReaderActivity,
private val items: WebtoonItems,
private val hideThreshold: Int,
) {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
var currentPage: Any? = null
private set
/**
* Marks the item at [position], the last one whose end is visible, as active.
*/
fun onScrolled(position: Int) {
val item = items.items.getOrNull(position)
val allowPreload = checkAllowPreload(item as? ReaderPage)
if (item != null && currentPage != item) {
currentPage = item
when (item) {
is ReaderPage -> onPageSelected(item, allowPreload)
is ChapterTransition -> onTransitionSelected(item)
}
}
}
/**
* Called when the list scrolled by [dy] pixels, leaving the items from [firstVisible] to [lastEndVisible] on
* screen.
*/
fun onScrolledBy(dy: Float, firstVisible: Int, lastEndVisible: Int) {
if (abs(dy) > hideThreshold && activity.viewModel.state.value.menuVisible) {
activity.hideMenu()
}
if (dy < 0) {
val firstItem = items.items.getOrNull(firstVisible)
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
activity.requestPreloadChapter(firstItem.to)
}
}
val lastItem = items.items.getOrNull(lastEndVisible)
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
activity.showMenu()
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going between pages of same chapter
// 2. Next chapter page
return when (page.chapter) {
(currentPage as? ReaderPage)?.chapter -> true
lastChapter() -> true
else -> false
}
}
/**
* Called when a [page] is marked as active. It notifies the activity of the change and requests the preload of
* the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
val transitionChapter = lastChapter()
if (transitionChapter != null) {
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
activity.requestPreloadChapter(transitionChapter)
}
}
}
/**
* Called when a [transition] is marked as active. It request the preload of the destination chapter of the
* transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
}
}
/**
* The chapter at the end of the list: the next transition's, or that of the next chapter's pages.
*/
private fun lastChapter() = when (val lastItem = items.items.lastOrNull()) {
is ChapterTransition.Next -> lastItem.to
is ReaderPage -> lastItem.chapter
else -> null
}
}
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.view.MotionEvent
@@ -23,14 +22,6 @@ class WebtoonStripImageView(context: Context) : View(context) {
private var imageHeight = 0
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
/** Applied to the strips as they draw. */
var colorFilter: ColorFilter?
get() = paint.colorFilter
set(value) {
paint.colorFilter = value
invalidate()
}
private val clipRect = Rect()
private val dstRect = Rect()
@@ -4,12 +4,21 @@ import android.view.Gravity
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp
import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.isNotEmpty
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.CircularProgressIndicator
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
/**
* Holder of the webtoon viewer that contains a chapter transition.
@@ -19,8 +28,20 @@ class WebtoonTransitionHolder(
viewer: WebtoonViewer,
) : WebtoonBaseHolder(layout, viewer) {
private val scope = MainScope()
private var stateJob: Job? = null
private val transitionView = ReaderTransitionView(context)
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
}
init {
layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
layout.orientation = LinearLayout.VERTICAL
@@ -30,22 +51,95 @@ class WebtoonTransitionHolder(
val paddingHorizontal = 32.dpToPx
layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
val childMargins = 16.dpToPx
val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(0, childMargins, 0, childMargins)
}
layout.addView(transitionView)
layout.addView(pagesContainer, childParams)
}
/**
* Binds the given [transition] with this view holder.
* Binds the given [transition] with this view holder, subscribing to its state.
*/
fun bind(transition: ChapterTransition) {
transitionView.bind(
transition = transition,
downloadState = viewer.downloadManager.transitionDownloadState(
transition,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
),
onRetry = viewer.activity::requestPreloadChapter,
statusPadding = PaddingValues(vertical = 16.dp),
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
)
transition.to?.let { observeStatus(it, transition) }
}
/**
* Called when the view is recycled and being added to the view pool.
*/
override fun recycle() {
stateJob?.cancel()
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter, transition: ChapterTransition) {
stateJob?.cancel()
stateJob = scope.launch {
chapter.stateFlow
.collectLatest { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error, transition)
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
// No additional view is added
}
}
pagesContainer.isVisible = pagesContainer.isNotEmpty()
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable, transition: ChapterTransition) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
}
val retryBtn = AppCompatButton(context).apply {
wrapContent()
text = context.stringResource(MR.strings.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
}
@@ -1,15 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.graphics.ColorFilter
import android.graphics.PointF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asAndroidColorFilter
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -19,18 +15,12 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.GeneralSettings
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
import eu.kanade.tachiyomi.ui.reader.viewer.LongStripSettings
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.collectChanges
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import mihon.app.di.appGraph
import tachiyomi.core.common.util.system.logcat
import java.util.Collections
import java.util.WeakHashMap
import kotlin.math.max
import kotlin.math.min
@@ -65,41 +55,25 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
*/
private val layoutManager = WebtoonLayoutManager(activity, scrollDistance)
private val settingsViewModel = activity.settingsViewModel
/**
* Settings every long strip viewer uses, like crop image borders.
* Configuration used by this viewer, like allow taps, or crop image borders.
*/
val longStrip: LongStripSettings
get() = settingsViewModel.longStrip.value
/**
* Settings only the webtoon viewers use, like side padding or tap zones.
*/
val simpleLongStrip: SimpleLongStripSettings
get() = settingsViewModel.simpleLongStrip.value
/**
* Settings shared by every viewer, like allow taps or page transitions.
*/
val general: GeneralSettings
get() = settingsViewModel.general.value
private val webtoonItems = WebtoonItems()
val config = WebtoonConfig(scope, readerPreferences)
/**
* Adapter of the recycler view.
*/
private val adapter = WebtoonAdapter(this, webtoonItems)
private val adapter = WebtoonAdapter(this)
private val tracker = WebtoonPageTracker(
activity,
webtoonItems,
readerPreferences.readerHideThreshold.get().threshold,
)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
private val threshold: Int by lazy { readerPreferences.readerHideThreshold.get().threshold }
init {
recycler.sidePaddingPercent = simpleLongStrip.page.sidePadding
recycler.sidePaddingPercent = config.sidePadding
recycler.setItemViewCacheSize(RECYCLER_VIEW_CACHE_SIZE)
recycler.isVisible = false // Don't let the recycler layout yet
recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
@@ -111,11 +85,24 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
onScrolled()
tracker.onScrolledBy(
dy = dy.toFloat(),
firstVisible = layoutManager.findFirstVisibleItemPosition(),
lastEndVisible = layoutManager.findLastEndVisibleItemPosition(),
)
if ((dy > threshold || dy < -threshold) && activity.viewModel.state.value.menuVisible) {
activity.hideMenu()
}
if (dy < 0) {
val firstIndex = layoutManager.findFirstVisibleItemPosition()
val firstItem = adapter.items.getOrNull(firstIndex)
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
activity.requestPreloadChapter(firstItem.to)
}
}
val lastIndex = layoutManager.findLastEndVisibleItemPosition()
val lastItem = adapter.items.getOrNull(lastIndex)
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
activity.showMenu()
}
}
},
)
@@ -124,14 +111,18 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
recycler.getLocationOnScreen(viewPosition)
val viewPositionRelativeToWindow = IntArray(2)
recycler.getLocationInWindow(viewPositionRelativeToWindow)
val pos = Offset(
val pos = PointF(
(event.rawX - viewPosition[0] + viewPositionRelativeToWindow[0]) / recycler.width,
(event.rawY - viewPosition[1] + viewPositionRelativeToWindow[1]) / recycler.originalHeight,
)
activity.onViewerTap(pos)
when (config.navigator.getAction(pos)) {
NavigationRegion.MENU -> activity.toggleMenu()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
}
}
recycler.longTapListener = f@{ event ->
if (activity.viewModel.state.value.menuVisible || general.longTapEnabled) {
if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) {
val child = recycler.findChildViewUnder(event.x, event.y)
if (child != null) {
val position = recycler.getChildAdapterPosition(child)
@@ -145,54 +136,57 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
false
}
settingsViewModel.general.collectChanges(scope) { old, new ->
if (old != null && old.theme != new.theme) {
ActivityCompat.recreate(activity)
}
config.imagePropertyChangedListener = {
recycler.sidePaddingPercent = config.sidePadding
refreshAdapter()
}
settingsViewModel.longStrip.collectChanges(scope) { old, new ->
if (old != null && old.cropBorders != new.cropBorders) {
refreshAdapter()
}
if (old?.zoomOutDisabled != new.zoomOutDisabled) {
frame.zoomOutDisabled = new.zoomOutDisabled
}
config.themeChangedListener = {
ActivityCompat.recreate(activity)
}
settingsViewModel.simpleLongStrip.collectChanges(scope) { old, new ->
if (old != null && old.page != new.page) {
recycler.sidePaddingPercent = new.page.sidePadding
refreshAdapter()
}
if (old?.doubleTapZoom != new.doubleTapZoom) {
frame.doubleTapZoom = new.doubleTapZoom
}
config.doubleTapZoomChangedListener = {
frame.doubleTapZoom = it
}
config.zoomPropertyChangedListener = {
frame.zoomOutDisabled = it
}
config.navigationModeChangedListener = {
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
frame.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
frame.addView(recycler)
}
/** The page images' colours, for pages bound later - see [LocalPageColorFilter]. */
var pageColorFilter: ColorFilter? = null
private set
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
/** Every page view made, as the recycler's cached ones are neither children nor always bound again. */
private val pageViews = Collections.newSetFromMap(WeakHashMap<ReaderPageImageView, Boolean>())
// Initial opening - preload allowed
currentPage ?: return true
fun onPageViewCreated(view: ReaderPageImageView) {
pageViews += view
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
val nextChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as? ReaderPage)?.chapter
// Allow preload for
// 1. Going between pages of same chapter
// 2. Next chapter page
return when (page.chapter) {
(currentPage as? ReaderPage)?.chapter -> true
nextChapter -> true
else -> false
}
}
@Composable
override fun Content(modifier: Modifier) {
val filter = LocalPageColorFilter.current
val pageFilter = remember(filter) { filter?.asAndroidColorFilter() }
SideEffect {
pageColorFilter = pageFilter
pageViews.forEach { it.pageColorFilter = pageFilter }
}
AndroidView(factory = { frame }, modifier = modifier)
/**
* Returns the view this viewer uses.
*/
override fun getView(): View {
return frame
}
/**
@@ -203,11 +197,46 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
scope.cancel()
}
/**
* Called from the RecyclerView listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
val transitionChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as?ReaderPage)?.chapter
if (transitionChapter != null) {
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
activity.requestPreloadChapter(transitionChapter)
}
}
}
/**
* Called from the RecyclerView listener when a [transition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
}
}
/**
* Tells this viewer to set the given [chapters] as active.
*/
override fun setChapters(chapters: ViewerChapters) {
val forceTransition = general.alwaysShowChapterTransition || tracker.currentPage is ChapterTransition
val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
adapter.setChapters(chapters, forceTransition)
if (recycler.isGone) {
@@ -233,15 +262,24 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
}
}
private fun onScrolled(pos: Int? = null) {
tracker.onScrolled(pos ?: layoutManager.findLastEndVisibleItemPosition())
fun onScrolled(pos: Int? = null) {
val position = pos ?: layoutManager.findLastEndVisibleItemPosition()
val item = adapter.items.getOrNull(position)
val allowPreload = checkAllowPreload(item as? ReaderPage)
if (item != null && currentPage != item) {
currentPage = item
when (item) {
is ReaderPage -> onPageSelected(item, allowPreload)
is ChapterTransition -> onTransitionSelected(item)
}
}
}
/**
* Scrolls up by [scrollDistance].
*/
override fun moveBackward() {
if (general.usePageTransitions) {
private fun scrollUp() {
if (config.usePageTransitions) {
recycler.smoothScrollBy(0, -scrollDistance)
} else {
recycler.scrollBy(0, -scrollDistance)
@@ -251,14 +289,60 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
/**
* Scrolls down by [scrollDistance].
*/
override fun moveForward() {
if (general.usePageTransitions) {
private fun scrollDown() {
if (config.usePageTransitions) {
recycler.smoothScrollBy(0, scrollDistance)
} else {
recycler.scrollBy(0, scrollDistance)
}
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) scrollDown() else scrollUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) scrollUp() else scrollDown()
}
}
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_PAGE_UP,
-> if (isUp) scrollUp()
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_PAGE_DOWN,
-> if (isUp) scrollDown()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
return false
}
/**
* Notifies adapter of changes around the current page to trigger a relayout in the recycler.
* Used when an image configuration is changed.
@@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.util.lang
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
fun RectF.invert(invertMode: ReaderPreferences.TappingInvertMode): RectF {
val horizontal = invertMode.shouldInvertHorizontal
val vertical = invertMode.shouldInvertVertical
return when {
horizontal && vertical -> RectF(1f - this.right, 1f - this.bottom, 1f - this.left, 1f - this.top)
vertical -> RectF(this.left, 1f - this.bottom, this.right, 1f - this.top)
horizontal -> RectF(1f - this.right, this.top, 1f - this.left, this.bottom)
else -> this
}
}
@@ -18,7 +18,6 @@ import com.hippo.unifile.UniFile
import eu.kanade.domain.ui.model.ThemeMode
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.truncateCenter
import logcat.LogPriority
import mihon.app.di.appGraph
@@ -101,15 +100,15 @@ fun Context.createFileInCacheDir(name: String): File {
private val ReaderGrayBackgroundColor = Color.rgb(0x20, 0x21, 0x25)
/**
* Background color of the reader for the given [readerTheme].
* Background color of the reader for the given [readerTheme] preference value.
*
* Automatic resolves against the current night mode, matching the activity background.
* Automatic (3) resolves against the current night mode, matching the activity background.
*/
fun Context.readerBackgroundColor(readerTheme: ReaderPreferences.ReaderTheme): Int = when (readerTheme) {
ReaderPreferences.ReaderTheme.WHITE -> Color.WHITE
ReaderPreferences.ReaderTheme.BLACK -> Color.BLACK
ReaderPreferences.ReaderTheme.GRAY -> ReaderGrayBackgroundColor
ReaderPreferences.ReaderTheme.AUTOMATIC -> if (isNightMode()) ReaderGrayBackgroundColor else Color.WHITE
fun Context.readerBackgroundColor(readerTheme: Int): Int = when (readerTheme) {
0 -> Color.WHITE // White
2 -> ReaderGrayBackgroundColor // Gray
3 -> if (isNightMode()) ReaderGrayBackgroundColor else Color.WHITE // Automatic
else -> Color.BLACK // Black
}
/**
@@ -123,14 +122,13 @@ fun Context.createReaderThemeContext(): Context {
val preferences = graph.uiPreferences
val readerPreferences = graph.readerPreferences
val themeMode = preferences.themeMode.get()
val isDarkBackground = when (ReaderPreferences.ReaderTheme.fromValue(readerPreferences.readerTheme.get())) {
ReaderPreferences.ReaderTheme.BLACK, ReaderPreferences.ReaderTheme.GRAY -> true
// Automatic bg uses activity background by default
ReaderPreferences.ReaderTheme.AUTOMATIC -> when (themeMode) {
val isDarkBackground = when (readerPreferences.readerTheme.get()) {
1, 2 -> true // Black, Gray
3 -> when (themeMode) { // Automatic bg uses activity background by default
ThemeMode.SYSTEM -> applicationContext.isNightMode()
else -> themeMode == ThemeMode.DARK
}
ReaderPreferences.ReaderTheme.WHITE -> false
else -> false // White
}
val expected = if (isDarkBackground) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
if (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK != expected) {
@@ -24,3 +24,19 @@ fun Context.activeNetworkState(): NetworkState {
isWifi = wifiManager.isWifiEnabled && capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false,
)
}
fun Context.networkStateFlow() = callbackFlow {
val networkCallback = object : NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
trySend(activeNetworkState())
}
override fun onLost(network: Network) {
trySend(activeNetworkState())
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
@@ -0,0 +1,11 @@
@file:Suppress("PackageDirectoryMismatch")
package androidx.preference
/**
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
*/
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
return onBindEditTextListener
}
@@ -1,25 +0,0 @@
@file:Suppress("PackageDirectoryMismatch")
package androidx.preference
/**
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
*/
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
return onBindEditTextListener
}
/**
* Calls [onChange] whenever this preference's state, visibility or children change, the package-private hook
* [PreferenceGroupAdapter] uses to rebind.
*/
fun Preference.setOnStateChangeListener(onChange: (Preference) -> Unit) {
setOnPreferenceChangeInternalListener(
object : Preference.OnPreferenceChangeInternalListener {
override fun onPreferenceChange(preference: Preference) = onChange(preference)
override fun onPreferenceHierarchyChange(preference: Preference) = onChange(preference)
override fun onPreferenceVisibilityChange(preference: Preference) = onChange(preference)
},
)
}
@@ -5,7 +5,6 @@ package eu.kanade.tachiyomi.util.view
import android.content.res.Resources
import android.graphics.Rect
import android.view.View
import android.widget.EditText
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.LocalContentColor
@@ -16,13 +15,8 @@ import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.inputmethod.EditorInfoCompat
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.theme.TachiyomiTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import mihon.app.di.appGraph
inline fun ComponentActivity.setComposeContent(
@@ -71,19 +65,3 @@ fun View?.isVisibleOnScreen(): Boolean {
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen)
}
/**
* Sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] in imeOptions while [BasePreferences.incognitoMode] is
* true. Some IMEs may not respect this flag.
*/
fun EditText.setIncognito(basePreferences: BasePreferences, viewScope: CoroutineScope) {
basePreferences.incognitoMode.changes()
.onEach {
imeOptions = if (it) {
imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
}
.launchIn(viewScope)
}
@@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.util.AttributeSet
import android.widget.EditText
import androidx.appcompat.R
import androidx.core.view.inputmethod.EditorInfoCompat
import com.google.android.material.textfield.TextInputEditText
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import mihon.app.di.appGraph
/**
* A custom [TextInputEditText] that sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] to imeOptions
* if [BasePreferences.incognitoMode] is true. Some IMEs may not respect this flag.
*
* @see setIncognito
*/
class TachiyomiTextInputEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle,
) : TextInputEditText(context, attrs, defStyleAttr) {
private var scope: CoroutineScope? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val basePreferences = context.appGraph.basePreferences
setIncognito(basePreferences, scope!!)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scope?.cancel()
scope = null
}
companion object {
/**
* Sets Flow to this [EditText] that sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] to imeOptions
* if [BasePreferences.incognitoMode] is true. Some IMEs may not respect this flag.
*/
fun EditText.setIncognito(basePreferences: BasePreferences, viewScope: CoroutineScope) {
basePreferences.incognitoMode.changes()
.onEach {
imeOptions = if (it) {
imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
}
.launchIn(viewScope)
}
}
}
+1 -1
View File
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.core.security.PrivacyPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.create.BackupCreateWorker
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreWorker
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -42,7 +43,6 @@ import eu.kanade.tachiyomi.util.CrashLogUtil
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import mihon.core.metro.IsDebugBuild
import mihon.data.cache.ChapterCache
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.repository.ExtensionStoreRepository
import nl.adaptivity.xmlutil.serialization.XML
-127
View File
@@ -1,127 +0,0 @@
package mihon.data.cache
import android.content.Context
import android.text.format.Formatter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.core.common.cache.TtlDiskCache
import mihon.core.metro.AppCoroutineScope
import okhttp3.Response
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import java.io.File
import java.io.IOException
import java.io.InputStream
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
/**
* The page lists and images of chapters read online.
*
* Page lists expire soon, as sources sign or rotate their image URLs. Images last longer.
*/
@Inject
@SingleIn(AppScope::class)
class ChapterCache(
private val context: Context,
private val json: Json,
@AppCoroutineScope scope: CoroutineScope,
) {
private val root = File(context.cacheDir, "chapter_cache")
// Out of the cache directory, so the system clearing that leaves the indexes
private val indexes = File(context.noBackupFilesDir, "chapter_cache")
private val pageLists = TtlDiskCache(File(root, "pages"), File(indexes, "pages.index"), 1.hours, scope)
private val images = TtlDiskCache(File(root, "images"), File(indexes, "images.index"), 1.days, scope)
init {
scope.launch {
pageLists.warmUp()
images.warmUp()
}
}
suspend fun readableSize(): String = Formatter.formatFileSize(context, pageLists.totalSize() + images.totalSize())
suspend fun getPageList(chapter: Chapter): List<Page>? {
val input = pageLists.open(chapter.key) ?: return null
return try {
json.decodeFromString(withContext(Dispatchers.IO) { input.bufferedReader().use { it.readText() } })
} catch (e: Exception) {
if (e is CancellationException) throw e
logcat(LogPriority.WARN, e) { "Failed to read page list from cache" }
pageLists.remove(chapter.key)
null
}
}
suspend fun putPageList(chapter: Chapter, pages: List<Page>) {
try {
val value = json.encodeToString(pages).toByteArray()
pageLists.put(chapter.key) { it.write(value) }
} catch (e: Exception) {
if (e is CancellationException) throw e
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
}
}
suspend fun isImageInCache(imageUrl: String): Boolean = imageUrl in images
/**
* The image's file, or null when it isn't cached.
*/
suspend fun getImageFile(imageUrl: String): File? = images.get(imageUrl)
/**
* Opens the image, or returns null when it isn't cached.
*/
suspend fun openImage(imageUrl: String): InputStream? = images.open(imageUrl)
suspend fun removeImage(imageUrl: String) {
images.remove(imageUrl)
}
/**
* Stores the image [response] holds, and closes it.
*
* @param live also receives the bytes as they arrive.
*/
@Throws(IOException::class)
suspend fun putImage(imageUrl: String, response: Response, live: DownloadStream? = null): File {
response.body.use { body ->
live?.reserve(body.contentLength())
return images.put(imageUrl) { out ->
body.source().use { source ->
val buffer = ByteArray(1 shl 16)
while (true) {
val n = source.read(buffer)
if (n < 0) break
out.write(buffer, 0, n)
live?.append(buffer, 0, n)
}
}
}
}
}
/**
* Removes everything, returning how many entries there were.
*/
suspend fun clear(): Int = pageLists.clear() + images.clear()
private val Chapter.key: String
get() = "$mangaId$url"
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.materialswitch.MaterialSwitch
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:clickable="false"
android:background="@null"/>
@@ -0,0 +1,31 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/reader_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/viewer_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:descendantFocusability="blocksDescendants" />
</FrameLayout>
<eu.kanade.tachiyomi.ui.reader.ReaderNavigationOverlayView
android:id="@+id/navigation_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:focusable="false"
android:visibility="gone" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center">
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/decode_image_error"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
android:id="@+id/action_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/action_retry" />
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
android:id="@+id/action_open_in_web_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/action_open_in_web_view"
android:visibility="gone" />
</LinearLayout>
+15
View File
@@ -29,6 +29,8 @@
<item name="android:textColor">?attr/colorOnBackground</item>
</style>
<style name="TextAppearance.Tachiyomi.Preference.Secondary" parent="TextAppearance.Material3.BodySmall" />
<!--=======================-->
<!--Widgets.MaterialDivider-->
@@ -49,4 +51,17 @@
<item name="elevationOverlayEnabled">@bool/elevationOverlayEnabled</item>
</style>
<!--===========-->
<!--Preferences-->
<!--===========-->
<style name="PreferenceThemeOverlay.Tachiyomi">
<item name="preferenceCategoryTitleTextAppearance">?attr/textAppearanceBodyMedium</item>
<item name="switchPreferenceCompatStyle">@style/PreferenceThemeOverlay.Tachiyomi.SwitchPreferenceCompat</item>
</style>
<style name="PreferenceThemeOverlay.Tachiyomi.SwitchPreferenceCompat" parent="Preference.SwitchPreferenceCompat.Material">
<item name="android:widgetLayout">@layout/pref_widget_switch_material</item>
</style>
</resources>

Some files were not shown because too many files have changed in this diff Show More