Compare commits

...
60 Commits
Author SHA1 Message Date
AntsyLich e588e04359 Share the reader's page chrome and page logic between viewers (#4193) 2026-10-26 23:36:46 +00:00
AntsyLich 9079ad8c9a Handle reader input and state in the activity (#4192) 2026-10-26 23:15:38 +00:00
AntsyLich 00335b2a70 Draw the reader through Compose (#4191) 2026-10-27 05:00:42 +06:00
Mend Renovate 7a672e9c6b Update dependency ca.mpreg:webgpuviewer to v54 (#4190) 2026-10-27 05:00:18 +06:00
Mend Renovate 96d904a5fc Update dependency ca.mpreg:imagedecoder to v18 (#4189) 2026-10-27 05:00:06 +06:00
AntsyLich 27994263da Replace the chapter cache's DiskLruCache with a TTL disk cache 2026-10-27 02:51:26 +06:00
AntsyLich 8124d324a1 Replace reader config listeners with settings view model state 2026-10-27 02:38:29 +06:00
AntsyLich ca028cb657 Move the reader viewer out of the ViewModel 2026-10-27 02:38:29 +06:00
AntsyLich ced89e3ae1 Convert extension source preferences screen to Compose
Closes #4183
2026-10-27 02:38:29 +06:00
AntsyLich cfeccaf08b Remove unused code, resources and strings 2026-10-26 22:54:05 +06:00
Mend Renovate 0606634750 Update metro to v1.5.0 (#4185) 2026-10-26 21:06:20 +06:00
AntsyLich 007ec9a868 Release v0.21.3
Keeps the changelog entries that f45d0ad31d removed for the release under
Unreleased, since main still has the WebGPU reader and the new image
decoder.

(cherry picked from commit feadad5e1e)
2026-10-26 03:39:36 +06:00
AntsyLich b19c8abeba Keep tab badges visible when the tab label is too long
Fixes #4165
Fixes #3423
Closes #3424
2026-10-26 03:33:19 +06:00
AntsyLich e8977b4a66 Revert "Keep tab badges visible when the tab label is too long (#4178)"
This reverts commit 7a5632c013.
2026-10-26 03:33:19 +06:00
AntsyLichandhamenot cdc7d69c92 Keep the installed extension when replacing it fails
Co-authored-by: hamenot <hamenot@users.noreply.github.com>
2026-10-26 02:29:53 +06:00
AntsyLich 3ed83a8d32 Decode only needed backup fields and restore entries in one pass
Closes #4168
2026-10-26 02:29:53 +06:00
AntsyLich bd2ada2e82 Fix Sui not being detected as a Shizuku provider
Closes #3785
2026-10-26 02:29:52 +06:00
SahilandAntsyLich 7a5632c013 Keep tab badges visible when the tab label is too long (#4178)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-10-26 02:27:40 +06:00
Mend Renovate 4310a224d4 Update dependency org.conscrypt:conscrypt-android to v2.8.0 (#4173) 2026-10-26 00:22:07 +06:00
Mend Renovate ab5f6585bc Update dependency com.google.firebase:firebase-bom to v35.1.0 (#4172) 2026-10-26 00:21:50 +06:00
Mend Renovate e48fc8711f Update dependency androidx.webkit:webkit to v1.18.0 (#4171) 2026-10-26 00:21:35 +06:00
Mend Renovate c8634ed089 Update okhttp to v5.5.1 (#4170) 2026-10-26 00:21:21 +06:00
Mend Renovate 8b8ee26af3 Update dependency androidx.compose:compose-bom-beta to v2026.10.01 (#4169) 2026-10-26 00:21:05 +06:00
AntsyLich d001e6f210 Move the download queue list to Compose 2026-10-21 02:17:15 +06:00
AntsyLich 2ca6961e1c Fix the More tab icon's exit animation 2026-10-21 02:17:15 +06:00
MajorTanya 0864ce0f46 Redact some sensitive headers in logcat (#4156) 2026-10-21 00:36:03 +06:00
wandAntsyLich 487c313d31 Bump webgpuviewer (#4146)
Co-authored-by: AntsyLich <59261191+antsylich@users.noreply.github.com>
2026-10-20 07:16:11 +00:00
AntsyLich 4493166985 Add missing changelog entries 2026-10-20 04:48:01 +06:00
AntsyLich b8070cdc9c Update the sources with the most entries first in library updates 2026-10-20 04:48:01 +06:00
AntsyLichandMend Renovate 5432ae74be Update dependency com.gradleup.tapmoc:tapmoc-gradle-plugin to v0.5.0
Closes #4133

Co-authored-by: Mend Renovate <bot@renovateapp.com>
2026-10-20 04:48:01 +06:00
AntsyLichandMend Renovate 90f6c8dcff Update markdown to v0.46.0
Closes #4134

Co-authored-by: Mend Renovate <bot@renovateapp.com>
2026-10-20 04:48:01 +06:00
AntsyLich a424282840 Fix covers reloading when removing manga from library 2026-10-20 04:48:01 +06:00
AntsyLich 8b9faa383b 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.
2026-10-20 04:47:55 +06:00
Mend Renovate 4750748dd1 Update Gradle to v9.8.2 (#4148) 2026-10-19 20:47:02 +00:00
Mend Renovate 6492feebe8 Update kotlinx.serialization to v1.12.0 (#4149) 2026-10-20 02:46:25 +06:00
Mend Renovate 1ff3a47e02 Update aboutLibraries to v15.3.0 (#4129) 2026-10-20 02:33:23 +06:00
Mend Renovate 18534bf477 Update actions/setup-java action to v6.1.0 (#4130) 2026-10-20 02:30:53 +06:00
+1 2e72b50f52 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>
2026-10-20 02:28:50 +06:00
AntsyLich deeb9b2584 Fix downloads not being found for sources sharing a stored source's name 2026-10-18 00:46:20 +06:00
AntsyLich a01867dc3e Only send source headers to the source in WebView 2026-10-18 00:46:20 +06:00
AntsyLich 5f63c983da Fix WebView renderer being killed by sources setting an Origin header 2026-10-18 00:46:20 +06:00
Mend Renovate 83e8f917dc Update dependency com.google.firebase:firebase-bom to v35.0.2 (#4127) 2026-10-17 16:27:16 +00:00
Mend Renovate 3058849b52 Update dependency io.kotest:kotest-assertions-core to v6.2.6 (#4128) 2026-10-17 16:26:37 +00:00
MajorTanya 970b12f1bd 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
2026-10-17 22:18:32 +06:00
+19 7618df898c 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>
2026-10-17 22:15:38 +06:00
Mend Renovate 446f6df82c Update androidx.paging to v3.5.3 (#4099) 2026-10-13 10:30:48 +06:00
AntsyLich 3100dceddd Release v0.21.2
Keeps the changelog entries that f45d0ad31d removed for the release under
Unreleased, since main still has the WebGPU reader and the new image
decoder.

(cherry picked from commit 9612d955db)
2026-10-13 10:27:57 +06:00
AntsyLichandNGB-Was-Taken 771ba33a33 Stream entries when creating and restoring backups
Closes #3850

Co-authored-by: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com>
2026-10-13 01:51:35 +06:00
AntsyLichandtigeryu8900 5a5ff22c37 Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
2026-10-13 01:51:35 +06:00
AntsyLich f0f1e4de5a Fix some downloads not being recognized 2026-10-12 22:33:31 +06:00
AntsyLich c371d6331d Ignore long strip taps confirmed after the reader is gone 2026-10-12 22:33:31 +06:00
AntsyLich 2df48e19a1 Fail the install when nothing can confirm it instead of crashing 2026-10-12 22:33:30 +06:00
AntsyLich c9e1b6e96e Show invalid filters from extensions instead of crashing 2026-10-12 22:33:30 +06:00
AntsyLich 3156b4a1c4 Insert split pages only into the chapter they belong to 2026-10-12 22:33:30 +06:00
hazptr ee59394542 Fix tracker start date being shifted by the UTC offset (#4049)
Closes #1340
Closes #4049
2026-10-12 22:33:30 +06:00
AntsyLich 7ae858e602 Solve Cloudflare challenges of non-GET requests on the site's root
Closes #3909
2026-10-12 22:33:30 +06:00
AntsyLich 43de88c718 Update navigationevent to stop back gestures crashing closed screens 2026-10-12 22:33:30 +06:00
AntsyLich ea2b73ad78 Guard the database update against duplicates and damaged indexes 2026-10-12 22:33:30 +06:00
MajorTanya 1ca9b0efb8 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.
2026-10-12 12:27:25 +00:00
AntsyLich 66e736222d Release v0.21.1
Keeps the changelog entries that f45d0ad31d removed for the release under
Unreleased, since main still has the WebGPU reader and the new image
decoder.

(cherry picked from commit e9670d8a12)
2026-10-12 14:22:38 +06:00
250 changed files with 7544 additions and 5150 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.21.0"
Example: "0.21.3"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
@@ -90,7 +90,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
+57 -12
View File
@@ -31,22 +31,64 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### 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
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich))
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich))
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich))
- 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
- Add a Time Format setting to customize how times are displayed ([@MajorTanya](https://github.com/MajorTanya)) ([#4118](https://github.com/mihonapp/mihon/pull/4118))
### Changed
- Update the sources with the most entries first during library updates ([@AntsyLich](https://github.com/AntsyLich)) ([`b8070cd`](https://github.com/mihonapp/mihon/commit/b8070cd))
### Improved
- Create and restore backups faster and with less memory ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken), [@AntsyLich](https://github.com/AntsyLich)) ([`771ba33`](https://github.com/mihonapp/mihon/commit/771ba33))
- Read backups faster before restoring, and decode each entry only once while restoring ([@AntsyLich](https://github.com/AntsyLich)) ([`3ed83a8`](https://github.com/mihonapp/mihon/commit/3ed83a8))
### Removed
- Remove the "Split tall images" download setting ([@AntsyLich](https://github.com/AntsyLich)) ([`8b9faa3`](https://github.com/mihonapp/mihon/commit/8b9faa3))
### Fixed
- Fixed WebView crashing for sources that set an Origin header ([@AntsyLich](https://github.com/AntsyLich)) ([`5f63c98`](https://github.com/mihonapp/mihon/commit/5f63c98))
- Fixed WebView sending source headers to other sites ([@AntsyLich](https://github.com/AntsyLich)) ([`a01867d`](https://github.com/mihonapp/mihon/commit/a01867d))
- Fixed downloads not showing for entries of a source sharing its name with another source ([@AntsyLich](https://github.com/AntsyLich)) ([`deeb9b2`](https://github.com/mihonapp/mihon/commit/deeb9b2))
- Fixed covers reloading when removing an entry from the library ([@AntsyLich](https://github.com/AntsyLich)) ([`a424282`](https://github.com/mihonapp/mihon/commit/a424282))
- Fixed the More tab icon pausing and then bouncing back when leaving the tab ([@AntsyLich](https://github.com/AntsyLich)) ([`2ca6961`](https://github.com/mihonapp/mihon/commit/2ca6961))
- Fixed Shizuku extension installer being unavailable with Sui ([@AntsyLich](https://github.com/AntsyLich)) ([`bd2ada2`](https://github.com/mihonapp/mihon/commit/bd2ada2))
## [v0.21.2] - 2026-10-13
### Fixed
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ([`ea2b73a`](https://github.com/mihonapp/mihon/commit/ea2b73a))
- Fixed a crash when a back gesture was still in progress as the reader or a dialog closed ([@AntsyLich](https://github.com/AntsyLich)) ([`43de88c`](https://github.com/mihonapp/mihon/commit/43de88c))
- Fixed Kitsu login and other requests that send data failing with "Failed to bypass Cloudflare" ([@AntsyLich](https://github.com/AntsyLich)) ([`7ae858e`](https://github.com/mihonapp/mihon/commit/7ae858e))
- Fixed tracker start date being off by a day when adding tracking to an entry with read history in some timezones ([@hazptr](https://github.com/hazptr)) ([#4049](https://github.com/mihonapp/mihon/pull/4049))
- Fixed the paged reader crashing when moving to another chapter after a wide page of a neighbouring chapter was split ([@AntsyLich](https://github.com/AntsyLich)) ([`3156b4a`](https://github.com/mihonapp/mihon/commit/3156b4a))
- Fixed a crash when opening the filters of a source with an invalid filter ([@AntsyLich](https://github.com/AntsyLich)) ([`c9e1b6e`](https://github.com/mihonapp/mihon/commit/c9e1b6e))
- Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) ([`2df48e1`](https://github.com/mihonapp/mihon/commit/2df48e1))
- Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) ([`c371d63`](https://github.com/mihonapp/mihon/commit/c371d63))
- Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) ([`f0f1e4d`](https://github.com/mihonapp/mihon/commit/f0f1e4d))
- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ([`5a5ff22`](https://github.com/mihonapp/mihon/commit/5a5ff22))
## [v0.21.1] - 2026-10-12
### Fixed
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich)) ([`8457fb9`](https://github.com/mihonapp/mihon/commit/8457fb9))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich)) ([`abb126a`](https://github.com/mihonapp/mihon/commit/abb126a))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich)) ([`c279291`](https://github.com/mihonapp/mihon/commit/c279291))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich)) ([`07b9fb6`](https://github.com/mihonapp/mihon/commit/07b9fb6))
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich)) ([`6dffca1`](https://github.com/mihonapp/mihon/commit/6dffca1))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich)) ([`649fc83`](https://github.com/mihonapp/mihon/commit/649fc83))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich)) ([`ae749bb`](https://github.com/mihonapp/mihon/commit/ae749bb))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich)) ([`a999e49`](https://github.com/mihonapp/mihon/commit/a999e49))
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich)) ([`9dbb714`](https://github.com/mihonapp/mihon/commit/9dbb714))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich)) ([`010ac71`](https://github.com/mihonapp/mihon/commit/010ac71))
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich)) ([`51b61a3`](https://github.com/mihonapp/mihon/commit/51b61a3))
## [v0.21.0] - 2026-10-12
### Added
@@ -794,7 +836,10 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.0...main
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.3...main
[v0.21.3]: https://github.com/mihonapp/mihon/compare/v0.21.2...v0.21.3
[v0.21.2]: https://github.com/mihonapp/mihon/compare/v0.21.1...v0.21.2
[v0.21.1]: https://github.com/mihonapp/mihon/compare/v0.21.0...v0.21.1
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
+3 -4
View File
@@ -37,8 +37,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 36
versionName = "0.21.0"
versionCode = 39
versionName = "0.21.3"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -229,6 +229,7 @@ dependencies {
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator)
@@ -272,7 +273,6 @@ dependencies {
implementation(libs.jsoup)
// Disk
implementation(libs.diskLruCache)
implementation(libs.unifile)
// Preferences
@@ -295,7 +295,6 @@ dependencies {
// UI libraries
implementation(libs.material)
implementation(libs.flexibleAdapter)
implementation(libs.photoView)
implementation(libs.directionalViewPager) {
exclude(group = "androidx.viewpager", module = "viewpager")
@@ -59,30 +59,6 @@ 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].
*
@@ -8,10 +8,8 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -61,10 +59,7 @@ class AddTracks(
.minOrNull()
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
val startDate = firstReadChapterDate.toEpochMilliseconds()
track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate),
)
@@ -1,17 +1,22 @@
package eu.kanade.domain.ui
import android.text.format.DateFormat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.ui.model.AppTheme
import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode
import eu.kanade.domain.ui.model.TimeFormat
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
import logcat.LogPriority
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.util.system.logcat
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
@@ -38,14 +43,53 @@ class UiPreferences(
val dateFormat: Preference<String> = preferenceStore.getString("app_date_format", "")
val timeFormat: Preference<TimeFormat> = preferenceStore.getEnum("app_time_format", TimeFormat.FOLLOW_LANGUAGE)
val tabletUiMode: Preference<TabletUiMode> = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)
val imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true)
companion object {
fun dateFormat(format: String): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
fun dateFormat(format: String, locale: Locale): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
else -> DateTimeFormatter.ofPattern(format, locale)
}
fun timeFormat(timeFormat: TimeFormat, locale: Locale, is24HourSystem: Boolean): DateTimeFormatter {
val use24Hours = when (timeFormat) {
TimeFormat.FOLLOW_LANGUAGE -> {
return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}
TimeFormat.FOLLOW_SYSTEM -> is24HourSystem
TimeFormat.HOURS_24 -> true
TimeFormat.HOURS_12 -> false
}
val skeleton = if (use24Hours) "Hm" else "hm"
val pattern = DateFormat.getBestDateTimePattern(locale, skeleton)
return try {
DateTimeFormatter.ofPattern(pattern, locale)
} catch (e: IllegalArgumentException) {
val fallbackPattern = if (use24Hours) "HH:mm" else "h:mm a"
logcat(LogPriority.ERROR, e) {
"Failed to apply best pattern ('$pattern'), falling back to '$fallbackPattern'"
}
DateTimeFormatter.ofPattern(fallbackPattern, locale)
}
}
fun dateTimeFormat(
dateFormat: String,
timeFormat: TimeFormat,
locale: Locale,
is24HourSystem: Boolean,
): DateTimeFormatter {
return DateTimeFormatterBuilder()
.append(dateFormat(dateFormat, locale))
.appendLiteral(' ')
.append(timeFormat(timeFormat, locale, is24HourSystem))
.toFormatter(locale)
}
}
}
@@ -0,0 +1,11 @@
package eu.kanade.domain.ui.model
import dev.icerock.moko.resources.StringResource
import tachiyomi.i18n.MR
enum class TimeFormat(val titleRes: StringResource) {
FOLLOW_LANGUAGE(MR.strings.follow_language),
FOLLOW_SYSTEM(MR.strings.follow_system),
HOURS_24(MR.strings.time_24_hours),
HOURS_12(MR.strings.time_12_hours),
}
@@ -3,6 +3,7 @@ package eu.kanade.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.datetime.LocalDate
@@ -27,10 +28,11 @@ fun relativeDateText(
localDate: LocalDate?,
): String {
val context = LocalContext.current
val locale = LocalLocale.current
val preferences = remember { context.appGraph.uiPreferences }
val relativeTime = remember { preferences.relativeTime.get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get()) }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get(), locale.platformLocale) }
return localDate?.toRelativeString(
context = context,
@@ -0,0 +1,494 @@
package eu.kanade.presentation.download
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.download.DownloadQueueSection
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.DragHandle
import mihon.icons.materialsymbols.rounded.MoreVert
import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
import sh.calvin.reorderable.rememberScroller
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ListGroupHeader
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun DownloadQueueList(
sections: List<DownloadQueueSection>,
lazyListState: LazyListState,
contentPadding: PaddingValues,
onReorder: (List<Download>) -> Unit,
onMove: (Download, toTop: Boolean) -> Unit,
onMoveSeries: (Download, toTop: Boolean) -> Unit,
onCancel: (Download) -> Unit,
onCancelSeries: (Download) -> Unit,
modifier: Modifier = Modifier,
) {
val sectionsState = remember { sections.toMutableStateList() }
// While a section is dragged its downloads are hidden, so it moves past whole sections
var isSectionDragging by remember { mutableStateOf(false) }
// While a download is dragged only its own section's rows can be swapped with it
var draggingSectionId by remember { mutableStateOf<Long?>(null) }
// The release animation starts from the finger, so the last dragged row stays held while it settles too
var lastDraggedKey by remember { mutableStateOf<String?>(null) }
val listCoordinates = remember { CoordinatesHolder() }
val draggingSection by rememberUpdatedState(sectionsState.find { it.id == draggingSectionId })
// Auto-scroll would otherwise carry a download held at its section's edge off screen
val sectionScrollState = remember(lazyListState) {
LimitedScrollableState(lazyListState) { pixels ->
draggingSection?.let { lazyListState.limitScrollToSection(it, pixels) } ?: pixels
}
}
val reorderableState = rememberReorderableLazyListState(
lazyListState = lazyListState,
scrollThresholdPadding = contentPadding,
scroller = rememberScroller(
scrollableState = sectionScrollState,
pixelAmountProvider = { lazyListState.layoutInfo.viewportSize.height * 0.05f },
),
) { from, to ->
val fromKey = from.key as String
val toKey = to.key as String
when {
fromKey.startsWith(SECTION_KEY) && toKey.startsWith(SECTION_KEY) -> {
val fromIndex = sectionsState.indexOfFirst { it.key == fromKey }
val toIndex = sectionsState.indexOfFirst { it.key == toKey }
sectionsState.add(toIndex, sectionsState.removeAt(fromIndex))
}
// Downloads don't change section
fromKey.startsWith(DOWNLOAD_KEY) && toKey.startsWith(DOWNLOAD_KEY) -> {
val sectionIndex = sectionsState.indexOfFirst { section ->
section.downloads.any { it.key == fromKey }
}
val section = sectionsState[sectionIndex]
val toIndex = section.downloads.indexOfFirst { it.key == toKey }
if (toIndex != -1) {
val downloads = section.downloads.toMutableList()
val fromIndex = downloads.indexOfFirst { it.key == fromKey }
downloads.add(toIndex, downloads.removeAt(fromIndex))
sectionsState[sectionIndex] = section.copy(downloads = downloads)
}
}
}
}
LaunchedEffect(sections) {
if (!reorderableState.isAnyItemDragging) {
sectionsState.clear()
sectionsState.addAll(sections)
}
}
val onDragStopped = {
isSectionDragging = false
draggingSectionId = null
onReorder(sectionsState.flatMap { it.downloads })
}
val firstDownload = sectionsState.firstOrNull()?.downloads?.firstOrNull()
val lastDownload = sectionsState.lastOrNull()?.downloads?.lastOrNull()
LazyColumn(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { listCoordinates.value = it },
state = lazyListState,
contentPadding = contentPadding,
) {
sectionsState.forEach { section ->
item(key = section.key) {
ReorderableItem(reorderableState, section.key, enabled = draggingSectionId == null) { isDragging ->
DownloadQueueHeader(
section = section,
isDragging = isDragging,
onDragStarted = { isSectionDragging = true },
onDragStopped = onDragStopped,
)
}
}
if (!isSectionDragging) {
items(
items = section.downloads,
key = { it.key },
) { download ->
ReorderableItem(
state = reorderableState,
key = download.key,
enabled = draggingSectionId == null || draggingSectionId == section.id,
) { isDragging ->
DownloadQueueItem(
download = download,
isDragging = isDragging,
modifier = if (isDragging || download.key == lastDraggedKey) {
Modifier.keepInSection(
lazyListState = lazyListState,
listCoordinates = listCoordinates,
sectionKey = section.key,
lastDownloadKey = section.downloads.last().key,
)
} else {
Modifier
},
canMoveToTop = download != firstDownload,
canMoveToBottom = download != lastDownload,
onDragStarted = {
draggingSectionId = section.id
lastDraggedKey = download.key
},
onDragStopped = onDragStopped,
onMove = { toTop -> onMove(download, toTop) },
onMoveSeries = { toTop -> onMoveSeries(download, toTop) },
onCancel = { onCancel(download) },
onCancelSeries = { onCancelSeries(download) },
)
}
}
}
}
}
}
// Lazy list keys must fit in a Bundle
private const val SECTION_KEY = "section-"
private const val DOWNLOAD_KEY = "download-"
private val DownloadQueueSection.key get() = "$SECTION_KEY$id"
private val Download.key get() = "$DOWNLOAD_KEY${chapter.id}"
private class CoordinatesHolder {
var value: LayoutCoordinates? = null
}
/**
* [lazyListState], with every scroll step passed through [limit] first.
*/
private class LimitedScrollableState(
private val lazyListState: LazyListState,
private val limit: (Float) -> Float,
) : ScrollableState by lazyListState {
override suspend fun scroll(scrollPriority: MutatePriority, block: suspend ScrollScope.() -> Unit) {
lazyListState.scroll(scrollPriority) {
val scope = this
object : ScrollScope {
override fun scrollBy(pixels: Float) = scope.scrollBy(limit(pixels))
}.block()
}
}
}
/**
* Limits [pixels] so the list scrolls no further than the end of [section] reaching the bottom, or its header
* reaching the top, while that edge is on screen.
*/
private fun LazyListState.limitScrollToSection(section: DownloadQueueSection, pixels: Float): Float {
val items = layoutInfo.visibleItemsInfo
return if (pixels > 0) {
val last = items.firstOrNull { it.key == section.downloads.last().key } ?: return pixels
val contentEnd = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
pixels.coerceAtMost((last.offset + last.size - contentEnd).coerceAtLeast(0).toFloat())
} else {
val header = items.firstOrNull { it.key == section.key } ?: return pixels
pixels.coerceAtLeast((header.offset).coerceAtMost(0).toFloat())
}
}
/**
* Stops a dragged download at the edges of its section, between the header and the section's last slot, instead of
* letting it float over another source it can't be dropped in.
*/
@Composable
private fun Modifier.keepInSection(
lazyListState: LazyListState,
listCoordinates: CoordinatesHolder,
sectionKey: String,
lastDownloadKey: String,
): Modifier {
var correction by remember { mutableFloatStateOf(0f) }
return this
// Before the correction layer, so it sees where the drag put the row
.onGloballyPositioned { coordinates ->
val list = listCoordinates.value?.takeIf { it.isAttached } ?: return@onGloballyPositioned
val top = list.localPositionOf(coordinates, Offset.Zero).y
val layoutInfo = lazyListState.layoutInfo
val itemStart = layoutInfo.beforeContentPadding
val header = layoutInfo.visibleItemsInfo.firstOrNull { it.key == sectionKey }
val last = layoutInfo.visibleItemsInfo.firstOrNull { it.key == lastDownloadKey }
val minTop = header?.let { (itemStart + it.offset + it.size).toFloat() } ?: Float.NEGATIVE_INFINITY
val maxTop = last?.let { (itemStart + it.offset + it.size - coordinates.size.height).toFloat() }
?: Float.POSITIVE_INFINITY
correction = top.coerceIn(minTop, maxOf(minTop, maxTop)) - top
}
.graphicsLayer { translationY = correction }
}
@Composable
private fun ReorderableCollectionItemScope.DownloadQueueHeader(
section: DownloadQueueSection,
isDragging: Boolean,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
) {
Surface(
modifier = Modifier.padding(top = MaterialTheme.padding.small),
color = listItemContainerColor(isDragging),
shadowElevation = listItemElevation(isDragging),
) {
Row(
modifier = Modifier.padding(end = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
ListGroupHeader(
text = "${section.name} (${section.downloads.size})",
modifier = Modifier.weight(1f),
)
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
}
}
}
@Composable
private fun ReorderableCollectionItemScope.DownloadQueueItem(
download: Download,
isDragging: Boolean,
canMoveToTop: Boolean,
canMoveToBottom: Boolean,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
onMove: (toTop: Boolean) -> Unit,
onMoveSeries: (toTop: Boolean) -> Unit,
onCancel: () -> Unit,
onCancelSeries: () -> Unit,
modifier: Modifier = Modifier,
) {
val progress by remember(download) { download.pagesProgressFlow() }.collectAsState(null)
val animatedProgress by animateFloatAsState(progress?.fraction ?: 0f, label = "progress")
Surface(
modifier = modifier,
color = listItemContainerColor(isDragging),
shadowElevation = listItemElevation(isDragging),
) {
Row(
modifier = Modifier.padding(
start = MaterialTheme.padding.small,
end = MaterialTheme.padding.extraSmall,
),
verticalAlignment = Alignment.CenterVertically,
) {
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = MaterialTheme.padding.small),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
) {
Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
Text(
text = download.manga.title,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
progress?.let {
Text(
text = "${it.downloadedPages}/${it.pageCount}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
Text(
text = download.chapter.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.padding(top = MaterialTheme.padding.extraSmall),
)
}
DownloadQueueItemMenu(
canMoveToTop = canMoveToTop,
canMoveToBottom = canMoveToBottom,
onMove = onMove,
onMoveSeries = onMoveSeries,
onCancel = onCancel,
onCancelSeries = onCancelSeries,
)
}
}
}
@Composable
private fun ReorderableCollectionItemScope.DragHandle(onDragStarted: () -> Unit, onDragStopped: () -> Unit) {
Icon(
imageVector = MaterialSymbols.Rounded.DragHandle,
contentDescription = null,
modifier = Modifier
.draggableHandle(
onDragStarted = { onDragStarted() },
onDragStopped = onDragStopped,
)
.padding(MaterialTheme.padding.small),
)
}
@Composable
private fun DownloadQueueItemMenu(
canMoveToTop: Boolean,
canMoveToBottom: Boolean,
onMove: (toTop: Boolean) -> Unit,
onMoveSeries: (toTop: Boolean) -> Unit,
onCancel: () -> Unit,
onCancelSeries: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val closeMenu = { expanded = false }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = MaterialSymbols.Rounded.MoreVert,
contentDescription = stringResource(MR.strings.action_menu),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = closeMenu) {
if (canMoveToTop) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.action_move_to_top)) },
onClick = {
closeMenu()
onMove(true)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(MR.strings.action_move_to_top_all_for_series)) },
onClick = {
closeMenu()
onMoveSeries(true)
},
)
if (canMoveToBottom) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.action_move_to_bottom)) },
onClick = {
closeMenu()
onMove(false)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(MR.strings.action_move_to_bottom_all_for_series)) },
onClick = {
closeMenu()
onMoveSeries(false)
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.action_cancel)) },
onClick = {
closeMenu()
onCancel()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.cancel_all_for_series)) },
onClick = {
closeMenu()
onCancelSeries()
},
)
}
}
}
// A neutral lifted surface while dragged, rather than Material's tertiaryContainer list tint
@Composable
private fun listItemContainerColor(isDragging: Boolean): Color {
return if (isDragging) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.background
}
@Composable
private fun listItemElevation(isDragging: Boolean): Dp {
val elevation = ListItemDefaults.elevation()
return if (isDragging) elevation.draggedElevation else elevation.elevation
}
private class PagesProgress(
val pageCount: Int,
val downloadedPages: Int,
totalProgress: Int,
) {
val fraction = if (pageCount == 0) 0f else totalProgress / (pageCount * 100f)
}
/**
* Progress of the pages of this download, or null until its pages are known.
*/
private fun Download.pagesProgressFlow() = pagesFlow.flatMapLatest { pages ->
when {
pages == null -> flowOf(null)
pages.isEmpty() -> flowOf(PagesProgress(pageCount = 0, downloadedPages = 0, totalProgress = 0))
else ->
combine(pages.map { page -> combine(page.statusFlow, page.progressFlow, ::Pair) }) { states ->
PagesProgress(
pageCount = pages.size,
downloadedPages = states.count { (status, _) -> status == Page.State.Ready },
totalProgress = states.sumOf { (_, progress) -> progress },
)
}
}
}
@@ -6,9 +6,14 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.AppBarTitle
@@ -16,8 +21,10 @@ import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.LocalDate
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.DeleteSweep
import tachiyomi.domain.history.model.HistoryWithRelations
@@ -28,6 +35,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable
fun HistoryScreen(
@@ -99,6 +107,14 @@ private fun HistoryScreenContent(
onClickDelete: (HistoryWithRelations) -> Unit,
onClickFavorite: (HistoryWithRelations) -> Unit,
) {
val context = LocalContext.current
val locale = LocalLocale.current
val is24HourSystem = rememberIs24HourSystem()
val timeFormat by remember { context.appGraph.uiPreferences.timeFormat }.collectAsState()
val timeFormatter = remember(timeFormat, locale, is24HourSystem) {
UiPreferences.timeFormat(timeFormat, locale.platformLocale, is24HourSystem)
}
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
@@ -124,6 +140,7 @@ private fun HistoryScreenContent(
HistoryItem(
modifier = Modifier.animateItem(),
history = value,
timeFormatter = timeFormatter,
onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) },
onClickDelete = { onClickDelete(value) },
@@ -31,12 +31,15 @@ import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
private val HistoryItemHeight = 96.dp
@Composable
fun HistoryItem(
history: HistoryWithRelations,
timeFormatter: DateTimeFormatter,
onClickCover: () -> Unit,
onClickResume: () -> Unit,
onClickDelete: () -> Unit,
@@ -68,7 +71,9 @@ fun HistoryItem(
overflow = TextOverflow.Ellipsis,
style = textStyle,
)
val readAt = remember { history.readAt?.toTimestampString() ?: "" }
val readAt = remember(timeFormatter, history.readAt) {
history.readAt?.toTimestampString(timeFormatter) ?: ""
}
Text(
text = if (history.chapterNumber > -1) {
stringResource(
@@ -114,6 +119,7 @@ private fun HistoryItemPreviews(
Surface {
HistoryItem(
history = historyWithRelations,
timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT),
onClickCover = {},
onClickResume = {},
onClickDelete = {},
@@ -13,13 +13,3 @@ fun DotSeparatorText(
modifier = modifier,
)
}
@Composable
fun DotSeparatorNoSpaceText(
modifier: Modifier = Modifier,
) {
Text(
text = "•",
modifier = modifier,
)
}
@@ -105,6 +105,8 @@ private fun getMarkdownColors(): MarkdownColors {
inlineCodeBackground = codeBackground,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
highlightText = MaterialTheme.colorScheme.onSecondaryContainer,
highlightBackground = MaterialTheme.colorScheme.secondaryContainer,
)
}
@@ -144,20 +144,6 @@ 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,
@@ -97,6 +97,15 @@ object SettingsAdvancedScreen : SearchableSettings {
true
},
),
Preference.PreferenceItem.SwitchPreference(
preference = networkPreferences.redactSensitiveHeaders,
title = stringResource(MR.strings.pref_redact_sensitive_headers),
subtitle = stringResource(MR.strings.pref_redact_sensitive_headers_summary),
onValueChanged = {
context.toast(MR.strings.requires_app_restart)
true
},
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_debug_info),
onClick = { navigator.push(DebugInfoScreen()) },
@@ -7,17 +7,20 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.core.app.ActivityCompat
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode
import eu.kanade.domain.ui.model.TimeFormat
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
@@ -101,12 +104,14 @@ object SettingsAppearanceScreen : SearchableSettings {
): Preference.PreferenceGroup {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val locale = LocalLocale.current
val is24HourSystem = rememberIs24HourSystem()
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
val dateFormat by uiPreferences.dateFormat.collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
val formattedDateNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat, locale.platformLocale).format(now)
}
return Preference.PreferenceGroup(
@@ -130,18 +135,31 @@ object SettingsAppearanceScreen : SearchableSettings {
preference = uiPreferences.dateFormat,
entries = DateFormats
.associateWith {
val formattedDate = UiPreferences.dateFormat(it).format(now)
val formattedDate = UiPreferences.dateFormat(it, locale.platformLocale).format(now)
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
},
title = stringResource(MR.strings.pref_date_format),
),
Preference.PreferenceItem.ListPreference(
preference = uiPreferences.timeFormat,
entries = TimeFormat.entries
.associateWith {
val formattedTime = UiPreferences.timeFormat(
it,
locale.platformLocale,
is24HourSystem,
).format(now)
"${stringResource(it.titleRes)} ($formattedTime)"
},
title = stringResource(MR.strings.pref_time_format),
),
Preference.PreferenceItem.SwitchPreference(
preference = uiPreferences.relativeTime,
title = stringResource(MR.strings.pref_relative_format),
subtitle = stringResource(
MR.strings.pref_relative_format_summary,
stringResource(MR.strings.relative_time_today),
formattedNow,
formattedDateNow,
),
),
Preference.PreferenceItem.SwitchPreference(
@@ -57,7 +57,6 @@ 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
@@ -287,7 +286,7 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { context.appGraph.chapterCache }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize by produceState("", cacheReadableSizeSema) {
value = withContext(Dispatchers.IO) { chapterCache.readableSize }
value = chapterCache.readableSize()
}
return Preference.PreferenceGroup(
@@ -311,7 +310,7 @@ object SettingsDataScreen : SearchableSettings {
onClick = {
scope.launch {
try {
val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
val deletedFiles = chapterCache.clear()
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
cacheReadableSizeSema++
} catch (e: Exception) {
@@ -43,11 +43,6 @@ object SettingsDownloadScreen : SearchableSettings {
preference = downloadPreferences.saveChaptersAsCBZ,
title = stringResource(MR.strings.save_chapter_as_cbz),
),
Preference.PreferenceItem.SwitchPreference(
preference = downloadPreferences.splitTallImages,
title = stringResource(MR.strings.split_tall_images),
subtitle = stringResource(MR.strings.split_tall_images_summary),
),
Preference.PreferenceItem.SliderPreference(
preference = downloadPreferences.parallelSourceLimit,
valueRange = 1..10,
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.about
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@@ -27,6 +28,7 @@ import eu.kanade.presentation.more.LogoHeader
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.util.LocalBackPress
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
@@ -61,6 +63,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Locale
import kotlin.time.Instant
object AboutScreen : Screen() {
@@ -74,6 +77,7 @@ object AboutScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow
var isCheckingUpdates by remember { mutableStateOf(false) }
val crashLogUtil = remember { context.appGraph.crashLogUtil }
val is24HourSystem = rememberIs24HourSystem()
Scaffold(
topBar = { scrollBehavior ->
@@ -96,7 +100,9 @@ object AboutScreen : Screen() {
item {
TextPreferenceWidget(
title = stringResource(MR.strings.version),
subtitle = getVersionName(withBuildDate = true),
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
// Date/time prefs only change in Appearance, which recreates this screen on return.
subtitle = remember(is24HourSystem) { getVersionName(withBuildDate = true) },
onPreferenceClick = {
val deviceInfo = crashLogUtil.getDebugInfo()
context.copyToClipboard("Debug information", deviceInfo)
@@ -275,11 +281,16 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
val context = Injekt.get<Context>()
val uiPreferences = context.appGraph.uiPreferences
Instant.parse(BuildConfig.BUILD_TIME)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<Context>().appGraph.uiPreferences.dateFormat.get(),
UiPreferences.dateTimeFormat(
uiPreferences.dateFormat.get(),
uiPreferences.timeFormat.get(),
Locale.getDefault(),
DateFormat.is24HourFormat(context),
),
)
} catch (_: Exception) {
@@ -18,6 +18,7 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.PreferenceScaffold
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
@@ -59,6 +60,7 @@ class DebugInfoScreen : Screen() {
private fun getAppInfoGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val is24HourSystem = rememberIs24HourSystem()
val installationIdPref = remember { context.appGraph.basePreferences.installationId }
val installationId by installationIdPref.collectAsState()
@@ -72,7 +74,9 @@ class DebugInfoScreen : Screen() {
),
Preference.PreferenceItem.TextPreference(
title = "Build time",
subtitle = AboutScreen.getFormattedBuildTime(),
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
// Date/time prefs only change in Appearance, which recreates this screen on return.
subtitle = remember(is24HourSystem) { AboutScreen.getFormattedBuildTime() },
),
Preference.PreferenceItem.TextPreference(
title = "Installation ID",
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.debug
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@@ -9,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -31,11 +33,13 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.lang.toDateTimestampString
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -46,6 +50,8 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.time.Instant
class WorkerInfoScreen : Screen() {
@@ -60,6 +66,12 @@ class WorkerInfoScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow
val viewModel = metroViewModel<WorkerInfoViewModel>()
val is24HourSystem = rememberIs24HourSystem()
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
// Date/time prefs only change in Appearance, which recreates this screen on return.
LaunchedEffect(is24HourSystem) { viewModel.onSystemTimeFormatChanged(is24HourSystem) }
val enqueued by viewModel.enqueued.collectAsState()
val finished by viewModel.finished.collectAsState()
val running by viewModel.running.collectAsState()
@@ -124,29 +136,53 @@ class WorkerInfoScreen : Screen() {
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class WorkerInfoViewModel(
private val context: Context,
context: Context,
) : ViewModel() {
private val workManager = context.workManager
private val dateFormatPref = context.appGraph.uiPreferences.dateFormat
private val timeFormatPref = context.appGraph.uiPreferences.timeFormat
private val is24HourSystem = MutableStateFlow(DateFormat.is24HourFormat(context))
val finished = workManager
.getWorkInfosFlow(
private val dateTimeFormatter = combine(
dateFormatPref.changes(),
timeFormatPref.changes(),
is24HourSystem,
) { date, time, is24 ->
UiPreferences.dateTimeFormat(date, time, Locale.getDefault(), is24)
}
fun onSystemTimeFormatChanged(value: Boolean) {
is24HourSystem.value = value
}
val finished = combine(
workManager.getWorkInfosFlow(
WorkQuery.fromStates(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED),
)
.map(::constructString)
),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val running = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map(::constructString)
val running = combine(
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val enqueued = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
.map(::constructString)
val enqueued = combine(
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
private fun constructString(list: List<WorkInfo>) = buildString {
private fun constructString(
list: List<WorkInfo>,
dateTimeFormatter: DateTimeFormatter,
) = buildString {
if (list.isEmpty()) {
appendLine("-")
} else {
@@ -160,11 +196,7 @@ class WorkerInfoScreen : Screen() {
if (workInfo.state == WorkInfo.State.ENQUEUED) {
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
context.appGraph.uiPreferences.dateFormat.get(),
),
)
.toDateTimestampString(dateTimeFormatter)
appendLine("Next scheduled run: $timestamp")
appendLine("Attempt #${workInfo.runAttemptCount + 1}")
}
@@ -25,6 +25,7 @@ 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
@@ -42,27 +43,32 @@ 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 = onClick != null, onClick = { onClick?.invoke() })
.clickable(enabled = enabled && onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Box(
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
modifier = Modifier
.padding(start = PrefsHorizontalPadding, end = 8.dp)
.alpha(contentAlpha),
content = { icon() },
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = PrefsVerticalPadding),
.padding(vertical = PrefsVerticalPadding)
.alpha(contentAlpha),
) {
if (!title.isNullOrBlank()) {
Text(
@@ -123,3 +129,4 @@ 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
@@ -0,0 +1,35 @@
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,6 +1,7 @@
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
@@ -46,11 +47,32 @@ fun <T> ListPreferenceWidget(
)
if (isDialogShown) {
AlertDialog(
ListPreferenceDialog(
title = title,
value = value,
entries = entries,
onValueChange = onValueChange,
onDismissRequest = { isDialogShown = false },
title = { Text(text = title) },
text = {
Box {
)
}
}
@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)) {
val state = rememberLazyListState()
ScrollbarLazyColumn(state = state) {
entries.forEach { current ->
@@ -60,8 +82,8 @@ fun <T> ListPreferenceWidget(
label = current.value,
isSelected = isSelected,
onSelected = {
onValueChange(current.key!!)
isDialogShown = false
onValueChange(current.key)
onDismissRequest()
},
)
}
@@ -70,14 +92,19 @@ fun <T> ListPreferenceWidget(
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
},
confirmButton = {
TextButton(onClick = { isDialogShown = false }) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
}
},
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))
}
@Composable
@@ -1,5 +1,6 @@
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
@@ -10,6 +11,7 @@ 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
@@ -35,16 +37,37 @@ fun <T> MultiSelectListPreferenceWidget(
)
if (isDialogShown) {
val selected = remember {
entries.keys
.filter { values.contains(it) }
.toMutableStateList()
}
AlertDialog(
MultiSelectListPreferenceDialog(
title = title,
values = values,
entries = entries,
onValuesChange = onValuesChange,
onDismissRequest = { isDialogShown = false },
title = { Text(text = title) },
text = {
LazyColumn {
)
}
}
@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)) {
entries.forEach { current ->
item {
val isSelected = selected.contains(current.key)
@@ -62,25 +85,26 @@ fun <T> MultiSelectListPreferenceWidget(
}
}
}
},
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))
}
},
)
}
}
},
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))
}
},
)
}
@@ -19,6 +19,7 @@ fun SwitchPreferenceWidget(
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
@@ -30,9 +31,11 @@ fun SwitchPreferenceWidget(
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
enabled = enabled,
onPreferenceClick = { onCheckedChanged(!checked) },
)
}
@@ -24,6 +24,7 @@ fun TextPreferenceWidget(
icon: ImageVector? = null,
iconTint: Color = MaterialTheme.colorScheme.primary,
widget: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
onPreferenceClick: (() -> Unit)? = null,
) {
BasePreferenceWidget(
@@ -56,6 +57,7 @@ fun TextPreferenceWidget(
},
onClick = onPreferenceClick,
widget = widget,
enabled = enabled,
)
}
@@ -39,7 +39,7 @@ fun OrientationSelectDialog(
DialogContent(
orientation = orientation,
onChangeOrientation = {
viewModel.onChangeOrientation(it)
viewModel.setOrientation(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -0,0 +1,65 @@
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)
}
}
@@ -0,0 +1,93 @@
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.onChangeReadingMode(it)
viewModel.setReadingMode(it)
onChange(it.stringRes)
onDismissRequest()
},
@@ -8,16 +8,13 @@ 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.WebGpuViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerSettings
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
@@ -29,92 +26,41 @@ 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 viewer by viewModel.viewerFlow.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.onChangeReadingMode(it) },
onClick = { viewModel.setReadingMode(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
if (viewer is WebGpuViewer) {
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
val resolved = ReadingMode.fromPreference(
when {
readingMode == ReadingMode.DEFAULT -> default
else -> manga?.readingMode?.toInt() ?: default
},
)
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
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.map {
FilterChip(
selected = it == orientation,
onClick = { viewModel.onChangeOrientation(it) },
onClick = { viewModel.setOrientation(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
when (viewer) {
is WebtoonViewer -> WebtoonViewerSettings(viewModel)
is WebGpuViewer -> WebGpuViewerSettings(viewModel)
else -> PagerViewerSettings(viewModel)
val resolvedReadingMode by viewModel.readingMode.collectAsState()
if (resolvedReadingMode.type is ReadingMode.ViewerType.Webtoon) {
WebtoonViewerSettings(viewModel)
} else {
PagerViewerSettings(viewModel)
}
}
@@ -264,7 +210,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel
}
@Composable
private fun ColumnScope.TapZonesItems(
internal fun TapZonesItems(
selected: Int,
onSelect: (Int) -> Unit,
invertMode: ReaderPreferences.TappingInvertMode,
@@ -292,120 +238,3 @@ private fun ColumnScope.TapZonesItems(
}
}
}
@Composable
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
HeadingItem(MR.strings.webgpu_viewer)
val manga by viewModel.mangaFlow.collectAsState()
val viewer by viewModel.viewerFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
val resolved = ReadingMode.fromPreference(
when {
readingMode == ReadingMode.DEFAULT -> default
else -> manga?.readingMode?.toInt() ?: default
},
)
val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
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,
)
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)) },
)
}
}
}
}
@@ -0,0 +1,32 @@
package eu.kanade.presentation.util
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
@Composable
fun rememberIs24HourSystem(): Boolean {
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var is24 by remember { mutableStateOf(DateFormat.is24HourFormat(context)) }
DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
is24 = DateFormat.is24HourFormat(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return is24
}
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import cafe.adriel.voyager.core.stack.mutableStateStackOf
import com.kevinnzou.web.AccompanistWebChromeClient
import com.kevinnzou.web.AccompanistWebViewClient
@@ -108,6 +109,8 @@ fun WebViewScreenContent(
onDispose { isActive = false }
}
val headersHost = remember { url.toUri().host }
val webClient = remember {
object : AccompanistWebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
@@ -147,6 +150,9 @@ fun WebViewScreenContent(
// Ignore intents urls
if (url.startsWith("intent://")) return true
// Only send source headers to the source itself
if (request.url.host != headersHost) return false
// Only open valid web urls
if (url.startsWith("http") || url.startsWith("https")) {
if (url != view?.url) {
@@ -4,8 +4,12 @@ import android.content.Context
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.Backup
import kotlinx.serialization.SerializationException
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.protobuf.ProtoBuf
import okio.BufferedSource
import okio.buffer
import okio.gzip
import okio.source
@@ -19,9 +23,48 @@ class BackupDecoder(
private val parser: ProtoBuf,
) {
/**
* Decode a potentially-gzipped backup.
* Decode everything but the entries of a potentially-gzipped backup, counting the entries without decoding them.
*/
fun decode(uri: Uri): Backup {
fun decodeMetadata(uri: Uri): Metadata {
return read(uri) { reader ->
orInvalid {
var entryCount = 0
while (reader.nextEntry()) {
reader.skipEntry()
entryCount++
}
Metadata(entryCount, reader.readMetadata())
}
}
}
/**
* Like [decodeMetadata], but hands each entry to [onEntry] decoded as [entry], which can declare only the fields
* it needs so the rest are skipped.
*/
fun <T> decodeMetadata(uri: Uri, entry: DeserializationStrategy<T>, onEntry: (T) -> Unit): Metadata {
return read(uri) { reader ->
var entryCount = 0
while (orInvalid { reader.nextEntry() }) {
onEntry(orInvalid { reader.readEntry(entry) })
entryCount++
}
Metadata(entryCount, orInvalid { reader.readMetadata() })
}
}
/**
* Decode the entries of a potentially-gzipped backup one at a time.
*/
fun decodeEntries(uri: Uri): Flow<BackupManga> = flow {
read(uri) { reader ->
while (orInvalid { reader.nextEntry() }) {
emit(orInvalid { reader.readEntry() })
}
}
}
private inline fun <T> read(uri: Uri, block: (BackupReader) -> T): T {
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer()
@@ -29,22 +72,32 @@ class BackupDecoder(
require(2)
}
val id1id2 = peeked.readShort()
val backupString = when (id1id2.toInt()) {
val backupSource: BufferedSource = when (id1id2.toInt()) {
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
}
else -> source
}.use { it.readByteArray() }
try {
parser.decodeFromByteArray(Backup.serializer(), backupString)
} catch (_: SerializationException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
}
backupSource.use { block(BackupReader(it, parser)) }
}
}
// Only around reading, so the JSON error above and anything thrown downstream of emit keep their own messages
private inline fun <T> orInvalid(block: () -> T): T {
return try {
block()
} catch (e: IOException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown), e)
}
}
data class Metadata(
val entryCount: Int,
val backup: Backup,
)
companion object {
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
@@ -2,7 +2,10 @@ package eu.kanade.tachiyomi.data.backup
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.source.service.SourceManager
@Inject
@@ -18,8 +21,11 @@ class BackupFileValidator(
* @return List of missing sources or missing trackers.
*/
suspend fun validate(uri: Uri): Results {
val trackerIds = mutableSetOf<Int>()
val backup = try {
backupDecoder.decode(uri)
backupDecoder.decodeMetadata(uri, TrackedManga.serializer()) { manga ->
manga.tracking.mapTo(trackerIds) { it.syncId }
}.backup
} catch (e: Exception) {
throw IllegalStateException(e)
}
@@ -38,11 +44,7 @@ class BackupFileValidator(
.distinct()
.sorted()
val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = trackers
val missingTrackers = trackerIds
.mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn }
.map { it.name }
@@ -55,4 +57,10 @@ class BackupFileValidator(
val missingSources: List<String>,
val missingTrackers: List<String>,
)
// Only the field validation reads, so decoding skips the rest of each entry
@Serializable
private class TrackedManga(
@ProtoNumber(18) val tracking: List<BackupTracking> = emptyList(),
)
}
@@ -0,0 +1,158 @@
package eu.kanade.tachiyomi.data.backup
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationException
import kotlinx.serialization.protobuf.ProtoBuf
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
import java.io.IOException
/**
* Writes the same bytes as encoding a whole [Backup], without holding all of its entries at once.
*/
class BackupWriter(
private val sink: BufferedSink,
private val parser: ProtoBuf,
) {
var isEmpty = true
private set
fun writeEntry(entry: BackupManga) {
val bytes = parser.encodeToByteArray(BackupManga.serializer(), entry)
sink.writeVarint(ENTRY_TAG)
sink.writeVarint(bytes.size.toLong())
sink.write(bytes)
isEmpty = false
}
/**
* Writes the other fields after the entries, as [metadata] without entries of its own.
*/
fun writeMetadata(metadata: Backup) {
val bytes = parser.encodeToByteArray(Backup.serializer(), metadata)
sink.write(bytes)
if (bytes.isNotEmpty()) isEmpty = false
}
}
/**
* Reads an encoded [Backup] one entry at a time. Every other field is set aside for [readMetadata], wherever it
* appears.
*
* Malformed or truncated input throws an [IOException].
*/
class BackupReader(
private val source: BufferedSource,
private val parser: ProtoBuf,
) {
private val metadata = Buffer()
private var entryLength = NO_ENTRY
/**
* Moves to the next entry, which [readEntry] or [skipEntry] then consumes.
*
* @return false once the input is exhausted.
*/
fun nextEntry(): Boolean {
check(entryLength == NO_ENTRY)
while (!source.exhausted()) {
val tag = source.readVarint()
val fieldNumber = tag ushr 3
val wireType = (tag and 0x7).toInt()
if (fieldNumber == 0L) throw IOException("Invalid field number 0")
if (tag == ENTRY_TAG) {
entryLength = source.readLength()
return true
}
metadata.writeVarint(tag)
when (wireType) {
WIRE_TYPE_VARINT -> metadata.writeVarint(source.readVarint())
WIRE_TYPE_FIXED64 -> metadata.write(source, 8)
WIRE_TYPE_LENGTH_DELIMITED -> {
val length = source.readLength()
metadata.writeVarint(length)
metadata.write(source, length)
}
WIRE_TYPE_FIXED32 -> metadata.write(source, 4)
else -> throw IOException("Unsupported wire type $wireType")
}
}
return false
}
fun readEntry(): BackupManga = readEntry(BackupManga.serializer())
/**
* Decodes the entry as [deserializer], which can declare only the fields it needs so the rest are skipped.
*/
fun <T> readEntry(deserializer: DeserializationStrategy<T>): T {
val bytes = source.readByteArray(takeEntryLength())
return decode(deserializer, bytes)
}
fun skipEntry() {
source.skip(takeEntryLength())
}
/**
* Skips the remaining entries and decodes everything else as a [Backup] without entries.
*/
fun readMetadata(): Backup {
if (entryLength != NO_ENTRY) skipEntry()
while (nextEntry()) skipEntry()
return decode(Backup.serializer(), metadata.readByteArray())
}
private fun takeEntryLength(): Long {
check(entryLength != NO_ENTRY)
return entryLength.also { entryLength = NO_ENTRY }
}
private fun <T> decode(deserializer: DeserializationStrategy<T>, bytes: ByteArray): T {
return try {
parser.decodeFromByteArray(deserializer, bytes)
} catch (e: SerializationException) {
throw IOException(e)
}
}
}
private fun BufferedSource.readVarint(): Long {
var result = 0L
for (shift in 0 until Long.SIZE_BITS step 7) {
val byte = readByte().toInt()
result = result or ((byte and 0x7F).toLong() shl shift)
if ((byte and 0x80) == 0) return result
}
throw IOException("Malformed varint")
}
private fun BufferedSource.readLength(): Long {
val length = readVarint()
if (length !in 0..Int.MAX_VALUE) throw IOException("Invalid length $length")
return length
}
private fun BufferedSink.writeVarint(value: Long) {
var remaining = value
while ((remaining and 0x7FL.inv()) != 0L) {
writeByte((remaining and 0x7F).toInt() or 0x80)
remaining = remaining ushr 7
}
writeByte(remaining.toInt())
}
private const val WIRE_TYPE_VARINT = 0
private const val WIRE_TYPE_FIXED64 = 1
private const val WIRE_TYPE_LENGTH_DELIMITED = 2
private const val WIRE_TYPE_FIXED32 = 5
// Backup.backupManga is field 1, and each entry is length-delimited
private const val ENTRY_TAG = 1L shl 3 or WIRE_TYPE_LENGTH_DELIMITED.toLong()
private const val NO_ENTRY = -1L
@@ -8,6 +8,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.BackupWriter
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
@@ -20,6 +21,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
@@ -85,27 +88,31 @@ class BackupCreator(
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)
val backup = Backup(
backupManga = backupManga,
backupCategories = backupCategories(options),
backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
)
val byteArray = parser.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
file.openOutputStream()
.also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}
.sink().gzip().buffer().use {
it.write(byteArray)
val writer = BackupWriter(it, parser)
val sourceIds = mutableSetOf<Long>()
backupManga.collect { manga ->
writer.writeEntry(manga)
sourceIds += manga.source
}
writer.writeMetadata(
Backup(
backupManga = emptyList(),
backupCategories = backupCategories(options),
backupSources = backupSources(sourceIds),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
),
)
if (writer.isEmpty) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
}
val fileUri = file.uri
@@ -130,14 +137,14 @@ class BackupCreator(
return categoriesBackupCreator()
}
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
if (!options.libraryEntries) return emptyList()
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
if (!options.libraryEntries) return emptyFlow()
return mangaBackupCreator(mangas, options)
}
private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
return sourcesBackupCreator(mangas)
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> {
return sourcesBackupCreator(sourceIds)
}
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
@@ -7,11 +7,13 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import mihon.core.common.extensions.toByteArray
import mihon.core.common.extensions.toEpochMillisOrZero
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.track.repository.TrackRepository
@@ -20,62 +22,71 @@ import tachiyomi.domain.track.repository.TrackRepository
class MangaBackupCreator(
private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository,
private val categoryRepository: CategoryRepository,
private val historyRepository: HistoryRepository,
private val trackRepository: TrackRepository,
private val getCategories: GetCategories,
private val getHistory: GetHistory,
) {
suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangas.map {
backupManga(it, options)
/**
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once.
*/
operator fun invoke(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> = flow {
mangas.chunked(CHUNK_SIZE).forEach { chunk ->
backupMangas(chunk, options).forEach { emit(it) }
}
}
private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
// Entry for this manga
val mangaObject = manga.toBackupManga()
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
val mangaIds = mangas.map { it.id }
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
if (options.chapters) {
// Backup all the chapters
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
.map { it.toBackupChapter() }
.takeUnless { it.isEmpty() }
?.let { mangaObject.chapters = it }
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds)
// History is backed up by chapter URL, so it needs the chapters too
val chaptersByMangaId = if (options.chapters || options.history) {
chapterRepository.getChaptersByMangaIds(mangaIds)
} else {
emptyMap()
}
val categoriesByMangaId = if (options.categories) {
categoryRepository.getCategoriesByMangaIds(mangaIds)
} else {
emptyMap()
}
val tracksByMangaId = if (options.tracking) trackRepository.getTracksByMangaIds(mangaIds) else emptyMap()
val historyByMangaId = if (options.history) historyRepository.getHistoryByMangaIds(mangaIds) else emptyMap()
if (options.categories) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
return mangas.map { manga ->
val mangaObject = manga.toBackupManga()
val chapters = chaptersByMangaId[manga.id].orEmpty()
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
if (options.chapters) {
mangaObject.chapters = chapters.map { it.toBackupChapter() }
}
}
if (options.tracking) {
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
if (options.categories) {
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order }
}
}
if (options.history) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
val history = historyByMangaId.mapNotNull { history ->
if (options.tracking) {
mangaObject.tracking = tracksByMangaId[manga.id].orEmpty().map { it.toBackupTracking() }
}
if (options.history) {
val chapterUrlsById = chapters.associate { it.id to it.url }
mangaObject.history = historyByMangaId[manga.id].orEmpty().mapNotNull { history ->
// A chapter removed since its history was read takes that history with it
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
mangaObject
}
}
private companion object {
const val CHUNK_SIZE = 100
}
}
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.source.service.SourceManager
@@ -11,11 +10,8 @@ class SourcesBackupCreator(
private val sourceManager: SourceManager,
) {
suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
return mangas
.map(BackupManga::source)
.distinct()
.map { sourceManager.getOrStub(it).toBackupSource() }
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> {
return sourceIds.map { sourceManager.getOrStub(it).toBackupSource() }
}
}
@@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
@@ -97,7 +97,7 @@ class BackupRestorer(
}
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
val backup = backupDecoder.decode(uri)
val (entryCount, backup) = backupDecoder.decodeMetadata(uri)
// Store source mapping for error messages
val backupMaps = backup.backupSources
@@ -105,7 +105,7 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreSourceNames(backupMaps)
restoreAmount += backup.backupManga.size
restoreAmount += entryCount
}
if (options.categories) {
restoreAmount += 1
@@ -138,7 +138,7 @@ class BackupRestorer(
}
if (options.libraryEntries) {
restoreManga(
backup.backupManga,
uri,
if (options.categories) backup.backupCategories else emptyList(),
restoreCategoriesJob,
)
@@ -175,14 +175,15 @@ class BackupRestorer(
}
private fun CoroutineScope.restoreManga(
backupMangas: List<BackupManga>,
uri: Uri,
backupCategories: List<BackupCategory>,
categoriesRestoreJob: Job?,
) = launch {
categoriesRestoreJob?.join()
mangaRestorer.sortByNew(backupMangas)
backupDecoder.decodeEntries(uri)
.chunked(100)
.forEach { chunk ->
.collect { chunk ->
val restoredAsBatch = try {
ensureActive()
mangaRestorer.restore(chunk, backupCategories)
@@ -23,13 +23,6 @@ class MangaRestorer(
private val now = Clock.System.now().toLocalDateTime(timeZone)
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
return backupMangas
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
}
/**
* Restores [backupMangas] all together, so either every one of them is restored or none is.
*/
@@ -1,238 +0,0 @@
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.ui.reader.model.DownloadStream
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.
* @param live also receives the bytes as they arrive.
* @throws IOException image error.
*/
@Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response, live: DownloadStream? = null) {
// 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.
if (live == null) {
response.body.source().saveTo(editor.newOutputStream(0))
} else {
live.reserve(response.body.contentLength())
response.body.source().use { source ->
editor.newOutputStream(0).use { out ->
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)
}
}
}
}
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
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import ca.mpreg.imagedecoder.ImageDecoder
import coil3.Canvas
import coil3.Image
@@ -64,96 +63,73 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override suspend fun decode(): DecodeResult {
if (options.webtoonStrips) return decodeStrips()
val res = resources.source().use {
return resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec ->
DecodeResultImage(
dec.decodeNext(),
dec.isHdr,
dec.hdrHeadroom,
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
// 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,
)
}
// 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,
)
}
}
val srcWidth = res.width
val srcHeight = res.height
// newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering).
// Hand it back as-is; sampling is the caller's responsibility.
if (options.newDecoder) {
return DecodeResult(
image = res,
isSampled = false,
)
}
// Normal path: produce a Bitmap scaled to the requested output size.
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,
)
// Copy RGBA pixels from the native buffer into a full-resolution bitmap.
// We must do this while `res` (and its native memory) is still alive.
// HDR frames are half-float RGBA.
val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
val fullBitmap = createBitmap(srcWidth, srcHeight, config)
res.image.rewind()
fullBitmap.copyPixelsFromBuffer(res.image)
res.frame.close()
// Downsample if needed. sampleSize is a power-of-two factor; the target
// dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour.
val bitmap = if (sampleSize > 1) {
val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1)
val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1)
val scaled = fullBitmap.scale(scaledWidth, scaledHeight)
fullBitmap.recycle()
scaled
} else {
fullBitmap
}
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.
* 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.
*
* 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) = resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr }
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 (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 viewWidth = options.size.widthPx(options.scale) { frame.width }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = frame.width,
srcHeight = frame.height,
dstWidth = viewWidth,
dstHeight = options.size.heightPx(options.scale) { frame.height },
scale = options.scale,
)
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt()
val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth))
// Older GPUs can't draw a texture wider than this
.coerceAtMost(MAX_STRIP_WIDTH)
.coerceIn(1, frame.width)
val scale = stripWidth.toFloat() / frame.width
// Strips of about the same size in bytes, so each takes about as long to upload
@@ -179,7 +155,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
}
band.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth)
}
}
@@ -60,9 +60,9 @@ class MangaCoverFetcher(
get() = diskCacheKeyLazy.value
override suspend fun fetch(): FetchResult {
// Use custom cover if exists
// Use custom cover if exists, which only library manga can have
val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY)
if (useCustomCover) {
if (useCustomCover && isLibraryManga) {
val customCoverFile = customCoverFileLazy.value
if (customCoverFile.exists()) {
return fileLoader(customCoverFile)
@@ -9,7 +9,7 @@ import tachiyomi.domain.manga.model.Manga as DomainManga
class MangaKeyer : Keyer<DomainManga> {
override fun key(data: DomainManga, options: Options): String {
return if (data.hasCustomCover()) {
return if (data.favorite && data.hasCustomCover()) {
"${data.id};${data.coverLastModified}"
} else {
"${data.thumbnailUrl};${data.coverLastModified}"
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
private val coverCache: CoverCache,
) : Keyer<MangaCover> {
override fun key(data: MangaCover, options: Options): String {
return if (coverCache.hasCustomCover(data.mangaId)) {
return if (data.isMangaFavorite && coverCache.hasCustomCover(data.mangaId)) {
"${data.mangaId};${data.lastModified}"
} else {
"${data.url};${data.lastModified}"
@@ -186,7 +186,8 @@ class DownloadCache(
fun getTotalDownloadCount(): Int {
renewCache()
return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
// The disk index stores a folder shared by several ids once per id
return rootDownloadsDir.sourceDirs.values.distinctBy { it.dir?.uri }.sumOf { sourceDir ->
sourceDir.mangaDirs.values.sumOf { mangaDir ->
mangaDir.chapterDirs.size
}
@@ -390,20 +391,21 @@ class DownloadCache(
sources = getSources()
}
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
// A stored stub can share its name with an installed source, so a folder can belong to several ids
val sourceIdsByDirName = sources.groupBy({ provider.getSourceDirName(it).lowercase() }, { it.id })
rootDownloadsDirMutex.withLock {
val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory())
updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
.mapNotNull { dir ->
val sourceId = sourceMap[dir.name!!.lowercase()]
sourceId?.let { it to SourceDirectory(dir) }
.flatMap { dir ->
val sourceDir = SourceDirectory(dir)
sourceIdsByDirName[dir.name!!.lowercase()].orEmpty().map { it to sourceDir }
}
.toMap()
updatedRootDir.sourceDirs.values.map { sourceDir ->
updatedRootDir.sourceDirs.values.distinct().map { sourceDir ->
async {
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
@@ -231,7 +231,7 @@ class DownloadProvider(
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
): String {
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
/**
@@ -254,14 +254,18 @@ class DownloadProvider(
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
val othersChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
val splitChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
}
// Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
val scanlator = parts.chapterScanlator
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
}
} else {
emptyList()
@@ -272,6 +276,7 @@ class DownloadProvider(
// chapter names)
add(chapterNameV1)
addAll(othersChapterDirNames)
addAll(splitChapterDirNames)
addAll(unsanitizedScanlatorChapterDirNames)
}
}
@@ -291,7 +296,6 @@ class DownloadProvider(
val chapterDirName = parts.dirName(
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
enableHash = libraryPreferences.enableChapterNameHash.get(),
sanitizeScanlator = true,
)
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
@@ -330,9 +334,29 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
}
private val validDirNames = arrayOfNulls<String>(2)
private val validChapterNames = arrayOfNulls<String>(2)
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean): String {
val index = if (disallowNonAscii) 1 else 0
val dirName = validDirNames[index]
// The scanlator and the name are made valid together, as every version before 0.21.0 did
?: DiskUtil.buildValidFilename(
if (chapterScanlator.isNullOrBlank()) chapterName else "${chapterScanlator}_$chapterName",
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
DiskUtil.MAX_FILE_NAME_BYTES - 11,
disallowNonAscii,
)
.also { validDirNames[index] = it }
return if (enableHash) dirName + hashSuffix else dirName
}
/**
* The name 0.21.0 and 0.21.1 gave a chapter, which made only the chapter name valid and so differs for names
* starting with a space or dot, long names, and non-ASCII scanlators.
*/
fun splitDirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
return buildString {
if (!chapterScanlator.isNullOrBlank()) {
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
@@ -7,7 +7,6 @@ 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
@@ -50,6 +49,7 @@ 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,7 +64,6 @@ 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
@@ -544,15 +543,11 @@ class Downloader(
// If the image is already downloaded, do nothing. Otherwise download from network
val file = when {
imageFile != null -> imageFile
chapterCache.isImageInCache(
page.imageUrl!!,
) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename)
else -> downloadImage(page, download.source, tmpDir, filename)
else -> copyImageFromCache(page.imageUrl!!, tmpDir, filename)
?: downloadImage(page, download.source, tmpDir, filename)
}
// When the page is ready, set page path, progress (just in case) and status
splitTallImageIfNeeded(page, tmpDir)
page.uri = file.uri
page.progress = 100
page.status = Page.State.Ready
@@ -612,24 +607,25 @@ class Downloader(
}
/**
* Copies the image from cache to file in tmpDir.
* Copies the image from cache to file in tmpDir, or returns null when it isn't cached.
*
* @param cacheFile the file from cache.
* @param imageUrl the url of the image.
* @param tmpDir the temporary directory of the download.
* @param filename the filename of the image.
*/
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)
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)
}
}
}
val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile
} ?: return null
chapterCache.removeImage(imageUrl)
val extension = tmpFile.openInputStream().use { ImageUtil.findImageType(it) } ?: return tmpFile
tmpFile.renameTo("$filename.${extension.extension}")
cacheFile.delete()
return tmpFile
}
@@ -645,23 +641,6 @@ class Downloader(
return ImageUtil.getExtensionFromMimeType(mime) { file.openInputStream() }
}
private fun splitTallImageIfNeeded(page: Page, tmpDir: UniFile) {
if (!downloadPreferences.splitTallImages.get()) return
try {
val filenamePrefix = "%03d".format(Locale.ENGLISH, page.number)
val imageFile = tmpDir.listFiles()?.firstOrNull { it.name.orEmpty().startsWith(filenamePrefix) }
?: error(context.stringResource(MR.strings.download_notifier_split_page_not_found, page.number))
// If the original page was previously split, then skip
if (imageFile.name.orEmpty().startsWith("${filenamePrefix}__")) return
ImageUtil.splitTallImage(tmpDir, imageFile, filenamePrefix)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" }
}
}
/**
* Checks if the download was successful.
*
@@ -29,9 +29,6 @@ data class Download(
_pagesFlow.value = pages
}
val totalProgress: Int
get() = pages?.sumOf(Page::progress) ?: 0
val downloadedImages: Int
get() = pages?.count { it.status == Page.State.Ready } ?: 0
@@ -260,6 +260,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
.sortedByDescending { it.size }
.map { mangaInSource ->
async {
semaphore.withPermit {
@@ -50,20 +50,6 @@ 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,26 +294,6 @@ 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
*
@@ -33,6 +33,7 @@ import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom
import java.util.Base64
import kotlin.time.Duration.Companion.minutes
@@ -77,7 +78,14 @@ class ComickApi(
put("status", track.status.toApiListStatus())
if (track.last_chapter_read > 0) {
putJsonObject("progress") {
put("number", track.last_chapter_read)
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
} else {
put("progress", null)
@@ -140,7 +148,14 @@ class ComickApi(
put("rating", track.score.toInt().takeIf { it > 0 })
put("status", track.status.toApiListStatus())
putJsonObject("progress") {
put("number", track.last_chapter_read.toString())
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
}
.toString()
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -57,7 +58,16 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service.startActivity(userAction)
try {
service.startActivity(userAction)
} catch (e: ActivityNotFoundException) {
// Some devices have the system installer's confirmation screen disabled or removed
logcat(LogPriority.WARN, e) { "Nothing can confirm the install" }
activeSession?.let { (_, sessionId) ->
runCatching { packageInstaller.abandonSession(sessionId) }
}
continueQueue(InstallStep.Error)
}
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
continueQueue(InstallStep.Idle)
@@ -24,6 +24,7 @@ import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat
import java.io.File
import java.io.IOException
/**
* Class that handles the loading of the extensions. Supports two kinds of extensions:
@@ -80,12 +81,12 @@ internal object ExtensionLoader {
}
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
val staged = File(target.parentFile, "${target.name}.part")
try {
target.delete()
file.copyAndSetReadOnlyTo(target, overwrite = true)
} catch (e: Exception) {
target.delete()
throw e
file.copyAndSetReadOnlyTo(staged, overwrite = true)
if (!staged.renameTo(target)) throw IOException("Unable to replace ${target.name}")
} finally {
staged.delete()
}
if (currentExtension != null) {
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
@@ -1,49 +1,73 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.content.Context
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 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 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.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.compose.ui.window.DialogWindowProvider
import androidx.core.widget.doAfterTextChanged
import androidx.preference.CheckBoxPreference
import androidx.preference.DialogPreference
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen
import androidx.preference.R
import androidx.preference.forEach
import androidx.preference.TwoStatePreference
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.widget.TachiyomiTextInputEditText.Companion.setIncognito
import eu.kanade.tachiyomi.util.view.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() {
@@ -53,135 +77,290 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val source by produceState<Source?>(initialValue = null) {
value = context.appGraph.sourceManager.getOrStub(sourceId)
// 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)
}
if (source == null) {
val preferenceScreen = screen
if (preferenceScreen == null) {
LoadingScreen()
return
}
DisposableEffect(preferenceScreen) {
onDispose { preferenceScreen.dispose() }
}
SideEffect { savedDialogKey = preferenceScreen.dialogPreference?.key }
Scaffold(
topBar = {
AppBar(
title = source.toString(),
title = preferenceScreen.title,
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
FragmentContainer(
fragmentManager = (context as FragmentActivity).supportFragmentManager,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
) {
add(it, SourcePreferencesFragment.getInstance(sourceId), null)
val items = remember(preferenceScreen.version) { preferenceScreen.items() }
ScrollbarLazyColumn(contentPadding = contentPadding) {
items(items) { PreferenceRow(it) }
}
}
}
/**
* 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())
preferenceScreen.dialogPreference?.let {
PreferenceDialog(
preference = it,
onDismissRequest = { preferenceScreen.dialogPreference = null },
)
}
var initialized by rememberSaveable { mutableStateOf(false) }
AndroidView(
modifier = modifier,
factory = { context ->
FragmentContainerView(context)
.apply { id = containerId }
},
update = { view ->
if (!initialized) {
fragmentManager.commit { commit(view.id) }
initialized = true
} else {
fragmentManager.onContainerAvailable(view)
}
},
)
}
/** 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)
}
}
class SourcePreferencesFragment : PreferenceFragmentCompat() {
/**
* 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()
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)
// 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 }
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
lifecycleScope.launch {
preferenceScreen = populateScreen()
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)) }
}
}
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())
fun items(): List<PreferenceItem> = buildList { addVisible(screen) }
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)
}
}
}
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)
}
return sourceScreen
}
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 {
private const val SOURCE_ID = "source_id"
fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply {
arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
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
},
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
}
},
onDismissRequest = onDismissRequest,
)
is EditTextPreference -> EditTextPreferenceDialog(preference, title, message, onDismissRequest)
else -> LaunchedEffect(preference) { onDismissRequest() }
}
}
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()
}
/**
* 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 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
}
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
@@ -239,11 +239,8 @@ class BrowseSourceViewModel(
fun changeMangaFavorite(manga: Manga) {
appScope.launch {
val update = if (manga.favorite) {
val coverLastModified = manga.removeCovers(coverCache).coverLastModified
MangaUpdate(manga.id) {
favoriteAt = null
if (coverLastModified != manga.coverLastModified) this.coverLastModified = coverLastModified
}
manga.removeCovers(coverCache)
MangaUpdate(manga.id) { favoriteAt = null }
} else {
setMangaDefaultChapterFlags.await(manga)
addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source))
@@ -169,7 +169,7 @@ private fun Int.toTriStateFilter(): TriState {
Filter.TriState.STATE_IGNORE -> TriState.DISABLED
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
else -> throw IllegalStateException("Unknown TriState state: $this")
else -> TriState.DISABLED
}
}
@@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.ui.download
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
/**
* Adapter storing a list of downloads.
*
* @param downloadItemListener Listener called when an item of the list is released.
*/
class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter<AbstractFlexibleItem<*>>(
null,
downloadItemListener,
true,
) {
override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean {
// Don't let sub-items changing group
return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition))
}
interface DownloadItemListener {
fun onItemReleased(position: Int)
fun onMenuItemClick(position: Int, menuItem: MenuItem)
}
}
@@ -1,34 +0,0 @@
package eu.kanade.tachiyomi.ui.download
import android.annotation.SuppressLint
import android.view.View
import androidx.recyclerview.widget.ItemTouchHelper
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.ExpandableViewHolder
import eu.kanade.tachiyomi.databinding.DownloadHeaderBinding
class DownloadHeaderHolder(view: View, adapter: FlexibleAdapter<*>) : ExpandableViewHolder(view, adapter) {
private val binding = DownloadHeaderBinding.bind(view)
@SuppressLint("SetTextI18n")
fun bind(item: DownloadHeaderItem) {
setDragHandleView(binding.reorder)
binding.title.text = "${item.name} (${item.size})"
}
override fun onActionStateChanged(position: Int, actionState: Int) {
super.onActionStateChanged(position, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
binding.container.isDragged = true
mAdapter.collapseAll()
}
}
override fun onItemReleased(position: Int) {
super.onItemReleased(position)
binding.container.isDragged = false
mAdapter.expandAll()
(mAdapter as DownloadAdapter).downloadItemListener.onItemReleased(position)
}
}
@@ -1,64 +0,0 @@
package eu.kanade.tachiyomi.ui.download
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
data class DownloadHeaderItem(
val id: Long,
val name: String,
val size: Int,
) : AbstractExpandableHeaderItem<DownloadHeaderHolder, DownloadItem>() {
override fun getLayoutRes(): Int {
return R.layout.download_header
}
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
): DownloadHeaderHolder {
return DownloadHeaderHolder(view, adapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: DownloadHeaderHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadHeaderItem
if (id != other.id) return false
if (name != other.name) return false
if (size != other.size) return false
if (subItemsCount != other.subItemsCount) return false
if (subItems !== other.subItems) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + size
result = 31 * result + subItems.hashCode()
return result
}
init {
isHidden = false
isExpanded = true
isSelectable = false
}
}
@@ -1,101 +0,0 @@
package eu.kanade.tachiyomi.ui.download
import android.view.View
import androidx.recyclerview.widget.ItemTouchHelper
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.databinding.DownloadItemBinding
import eu.kanade.tachiyomi.util.view.popupMenu
/**
* Class used to hold the data of a download.
* All the elements from the layout file "download_item" are available in this class.
*
* @param view the inflated view for this holder.
* @constructor creates a new download holder.
*/
class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = DownloadItemBinding.bind(view)
init {
setDragHandleView(binding.reorder)
binding.menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
private lateinit var download: Download
/**
* Binds this holder with the given category.
*
* @param category The category to bind.
*/
fun bind(download: Download) {
this.download = download
// Update the chapter name.
binding.chapterTitle.text = download.chapter.name
// Update the manga title
binding.mangaFullTitle.text = download.manga.title
// Update the progress bar and the number of downloaded pages
val pages = download.pages
if (pages == null) {
binding.downloadProgress.progress = 0
binding.downloadProgress.max = 1
binding.downloadProgressText.text = ""
} else {
binding.downloadProgress.max = pages.size * 100
notifyProgress()
notifyDownloadedPages()
}
}
/**
* Updates the progress bar of the download.
*/
fun notifyProgress() {
val pages = download.pages ?: return
if (binding.downloadProgress.max == 1) {
binding.downloadProgress.max = pages.size * 100
}
binding.downloadProgress.setProgressCompat(download.totalProgress, true)
}
/**
* Updates the text field of the number of downloaded pages.
*/
fun notifyDownloadedPages() {
val pages = download.pages ?: return
binding.downloadProgressText.text = "${download.downloadedImages}/${pages.size}"
}
override fun onItemReleased(position: Int) {
super.onItemReleased(position)
adapter.downloadItemListener.onItemReleased(position)
binding.container.isDragged = false
}
override fun onActionStateChanged(position: Int, actionState: Int) {
super.onActionStateChanged(position, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
binding.container.isDragged = true
}
}
private fun showPopupMenu(view: View) {
view.popupMenu(
menuRes = R.menu.download_single,
initMenu = {
findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1
findItem(R.id.move_to_bottom).isVisible =
bindingAdapterPosition != adapter.itemCount - 1
},
onMenuItemClick = {
adapter.downloadItemListener.onMenuItemClick(bindingAdapterPosition, this)
},
)
}
}
@@ -1,68 +0,0 @@
package eu.kanade.tachiyomi.ui.download
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
class DownloadItem(
val download: Download,
header: DownloadHeaderItem,
) : AbstractSectionableItem<DownloadHolder, DownloadHeaderItem>(header) {
override fun getLayoutRes(): Int {
return R.layout.download_item
}
/**
* Returns a new view holder for this item.
*
* @param view The view of this item.
* @param adapter The adapter of this item.
*/
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
): DownloadHolder {
return DownloadHolder(view, adapter as DownloadAdapter)
}
/**
* Binds the given view holder with this item.
*
* @param adapter The adapter of this item.
* @param holder The holder to bind.
* @param position The position of this item in the adapter.
* @param payloads List of partial changes.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: DownloadHolder,
position: Int,
payloads: MutableList<Any>,
) {
holder.bind(download)
}
/**
* Returns true if this item is draggable.
*/
override fun isDraggable(): Boolean {
return true
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is DownloadItem) {
return download.chapter.id == other.download.chapter.id
}
return false
}
override fun hashCode(): Int {
return download.chapter.id.toInt()
}
}
@@ -1,11 +1,9 @@
package eu.kanade.tachiyomi.ui.download
import android.view.LayoutInflater
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -19,7 +17,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -27,17 +24,11 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.ViewCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.metroViewModel
@@ -45,10 +36,8 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.components.NestedMenuItem
import eu.kanade.presentation.download.DownloadQueueList
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.databinding.DownloadListBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Sort
import mihon.icons.materialsymbols.roundedfilled.Pause
@@ -58,18 +47,16 @@ import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import kotlin.math.roundToInt
object DownloadQueueScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val viewModel = metroViewModel<DownloadQueueViewModel>()
val downloadList by viewModel.state.collectAsStateWithLifecycle()
val downloadCount by remember {
derivedStateOf { downloadList.sumOf { it.subItems.size } }
derivedStateOf { downloadList.sumOf { it.downloads.size } }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -135,7 +122,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_newest)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.dateUpload },
{ it.chapter.dateUpload },
true,
)
closeMenu()
@@ -145,7 +132,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_oldest)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.dateUpload },
{ it.chapter.dateUpload },
false,
)
closeMenu()
@@ -160,7 +147,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_asc)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.chapterNumber },
{ it.chapter.chapterNumber },
false,
)
closeMenu()
@@ -170,7 +157,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_desc)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.chapterNumber },
{ it.chapter.chapterNumber },
true,
)
closeMenu()
@@ -240,49 +227,17 @@ object DownloadQueueScreen : Screen() {
return@Scaffold
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { context ->
viewModel.controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
viewModel.adapter = DownloadAdapter(viewModel.listener)
viewModel.controllerBinding.root.adapter = viewModel.adapter
viewModel.adapter?.isHandleDragEnabled = true
viewModel.controllerBinding.root.layoutManager = LinearLayoutManager(context)
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
scope.launch(Dispatchers.Main) {
viewModel.getDownloadStatusFlow()
.collect(viewModel::onStatusChange)
}
scope.launch(Dispatchers.Main) {
viewModel.getDownloadProgressFlow()
.collect(viewModel::onUpdateDownloadedPages)
}
viewModel.controllerBinding.root
},
update = {
viewModel.controllerBinding.root
.updatePadding(
left = left,
top = top,
right = right,
bottom = bottom,
)
viewModel.adapter?.updateDataSet(downloadList)
},
)
}
DownloadQueueList(
sections = downloadList,
lazyListState = rememberLazyListState(),
contentPadding = contentPadding,
onReorder = viewModel::reorder,
onMove = viewModel::move,
onMoveSeries = viewModel::moveSeries,
onCancel = { viewModel.cancel(listOf(it)) },
onCancelSeries = viewModel::cancelSeries,
modifier = Modifier.nestedScroll(nestedScrollConnection),
)
}
}
}
@@ -1,33 +1,22 @@
package eu.kanade.tachiyomi.ui.download
import android.view.MenuItem
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.databinding.DownloadListBinding
import eu.kanade.tachiyomi.source.model.Page
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Inject
@@ -37,117 +26,18 @@ class DownloadQueueViewModel(
private val downloadManager: DownloadManager,
) : ViewModel() {
val state: StateFlow<List<DownloadHeaderItem>> = downloadManager.queueState
val state: StateFlow<List<DownloadQueueSection>> = downloadManager.queueState
.map { downloads ->
downloads
.groupBy { it.source }
.map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
addSubItems(0, entry.value.map { DownloadItem(it, this) })
}
}
.map { (source, downloads) -> DownloadQueueSection(source.id, source.name, downloads) }
}
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
lateinit var controllerBinding: DownloadListBinding
/**
* Adapter containing the active downloads.
*/
var adapter: DownloadAdapter? = null
/**
* Map of jobs for active downloads.
*/
private val progressJobs = mutableMapOf<Download, Job>()
val listener = object : DownloadAdapter.DownloadItemListener {
/**
* Called when an item is released from a drag.
*
* @param position The position of the released item.
*/
override fun onItemReleased(position: Int) {
val adapter = adapter ?: return
val downloads = adapter.headerItems.flatMap { header ->
adapter.getSectionItems(header).map { item ->
(item as DownloadItem).download
}
}
reorder(downloads)
}
/**
* Called when the menu item of a download is pressed
*
* @param position The position of the item
* @param menuItem The menu Item pressed
*/
override fun onMenuItemClick(position: Int, menuItem: MenuItem) {
val item = adapter?.getItem(position) ?: return
if (item is DownloadItem) {
when (menuItem.itemId) {
R.id.move_to_top, R.id.move_to_bottom -> {
val headerItems = adapter?.headerItems ?: return
val newDownloads = mutableListOf<Download>()
headerItems.forEach { headerItem ->
headerItem as DownloadHeaderItem
if (headerItem == item.header) {
headerItem.removeSubItem(item)
if (menuItem.itemId == R.id.move_to_top) {
headerItem.addSubItem(0, item)
} else {
headerItem.addSubItem(item)
}
}
newDownloads.addAll(headerItem.subItems.map { it.download })
}
reorder(newDownloads)
}
R.id.move_to_top_series, R.id.move_to_bottom_series -> {
val (selectedSeries, otherSeries) = adapter?.currentItems
?.filterIsInstance<DownloadItem>()
?.map(DownloadItem::download)
?.partition { item.download.manga.id == it.manga.id }
?: Pair(emptyList(), emptyList())
if (menuItem.itemId == R.id.move_to_top_series) {
reorder(selectedSeries + otherSeries)
} else {
reorder(otherSeries + selectedSeries)
}
}
R.id.cancel_download -> {
cancel(listOf(item.download))
}
R.id.cancel_series -> {
val allDownloadsForSeries = adapter?.currentItems
?.filterIsInstance<DownloadItem>()
?.filter { item.download.manga.id == it.download.manga.id }
?.map(DownloadItem::download)
if (!allDownloadsForSeries.isNullOrEmpty()) {
cancel(allDownloadsForSeries)
}
}
}
}
}
}
override fun onCleared() {
for (job in progressJobs.values) {
job.cancel()
}
progressJobs.clear()
adapter = null
}
val isDownloaderRunning = downloadManager.isDownloaderRunning
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
fun getDownloadStatusFlow() = downloadManager.statusFlow()
fun getDownloadProgressFlow() = downloadManager.progressFlow()
fun startDownloads() {
downloadManager.startDownloads()
}
@@ -168,102 +58,59 @@ class DownloadQueueViewModel(
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
}
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
val adapter = adapter ?: return
val newDownloads = mutableListOf<Download>()
adapter.headerItems.forEach { headerItem ->
headerItem as DownloadHeaderItem
headerItem.subItems = headerItem.subItems.sortedBy(selector).toMutableList().apply {
if (reverse) {
reverse()
/**
* Moves [download] to the top or bottom of its section.
*/
fun move(download: Download, toTop: Boolean) {
reorder(
state.value.flatMap { section ->
if (download in section.downloads) {
val others = section.downloads - download
if (toTop) listOf(download) + others else others + download
} else {
section.downloads
}
}
newDownloads.addAll(headerItem.subItems.map { it.download })
}
reorder(newDownloads)
},
)
}
/**
* Called when the status of a download changes.
*
* @param download the download whose status has changed.
* Moves all the downloads of the manga of [download] to the top or bottom of the queue.
*/
fun onStatusChange(download: Download) {
when (download.status) {
Download.State.DOWNLOADING -> {
launchProgressJob(download)
// Initial update of the downloaded pages
onUpdateDownloadedPages(download)
}
Download.State.DOWNLOADED -> {
cancelProgressJob(download)
onUpdateProgress(download)
onUpdateDownloadedPages(download)
}
Download.State.ERROR -> cancelProgressJob(download)
else -> {
/* unused */
}
fun moveSeries(download: Download, toTop: Boolean) {
val (selectedSeries, otherSeries) = state.value
.flatMap { it.downloads }
.partition { it.manga.id == download.manga.id }
reorder(if (toTop) selectedSeries + otherSeries else otherSeries + selectedSeries)
}
fun cancelSeries(download: Download) {
val allDownloadsForSeries = state.value
.flatMap { it.downloads }
.filter { it.manga.id == download.manga.id }
if (allDownloadsForSeries.isNotEmpty()) {
cancel(allDownloadsForSeries)
}
}
/**
* Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
* Sorts the downloads of every section by [selector].
*/
private fun launchProgressJob(download: Download) {
val job = viewModelScope.launch {
val pages = download.pagesFlow.filterNotNull().first()
val progressFlows = pages.map(Page::progressFlow)
combine(progressFlows, Array<Int>::sum)
.distinctUntilChanged()
.debounce(50.milliseconds)
.collectLatest {
onUpdateProgress(download)
}
}
// Avoid leaking jobs
progressJobs.remove(download)?.cancel()
progressJobs[download] = job
}
/**
* Unsubscribes the given download from the progress subscriptions.
*
* @param download the download to unsubscribe.
*/
private fun cancelProgressJob(download: Download) {
progressJobs.remove(download)?.cancel()
}
/**
* Called when the progress of a download changes.
*
* @param download the download whose progress has changed.
*/
private fun onUpdateProgress(download: Download) {
getHolder(download)?.notifyProgress()
}
/**
* Called when a page of a download is downloaded.
*
* @param download the download whose page has been downloaded.
*/
fun onUpdateDownloadedPages(download: Download) {
getHolder(download)?.notifyDownloadedPages()
}
/**
* Returns the holder for the given download.
*
* @param download the download to find.
* @return the holder of the download or null if it's not bound.
*/
private fun getHolder(download: Download): DownloadHolder? {
return controllerBinding.root.findViewHolderForItemId(download.chapter.id) as? DownloadHolder
fun <R : Comparable<R>> reorderQueue(selector: (Download) -> R?, reverse: Boolean = false) {
reorder(
state.value.flatMap { section ->
section.downloads.sortedBy(selector).let { if (reverse) it.reversed() else it }
},
)
}
}
/**
* Downloads of the queue from one source.
*/
@Immutable
data class DownloadQueueSection(
val id: Long,
val name: String,
val downloads: List<Download>,
)
@@ -80,7 +80,6 @@ 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
@@ -99,7 +98,6 @@ 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
@@ -115,6 +113,7 @@ 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
@@ -325,9 +324,7 @@ class MainActivity : BaseActivity() {
setSplashScreenExitAnimation(splashScreen)
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
lifecycleScope.launch(Dispatchers.IO) {
chapterCache.clear()
}
lifecycleScope.launch { chapterCache.clear() }
}
}
@@ -414,10 +414,7 @@ class MangaViewModel(
if (isFavorited) {
// Remove from library
if (updateManga.awaitUpdateFavorite(manga.id, false)) {
// Remove covers and update last modified in db
if (manga.removeCovers(coverCache) != manga) {
updateManga.awaitUpdateCoverLastModified(manga.id)
}
manga.removeCovers(coverCache)
onRemoved()
}
} else {
@@ -26,6 +26,7 @@ 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.platform.LocalLocale
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -104,9 +105,15 @@ data class TrackInfoDialogHomeScreen(
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val locale = LocalLocale.current
val viewModel = assistedMetroViewModel<Model, Model.Factory> { create(mangaId = mangaId, sourceId = sourceId) }
val dateFormat = remember { UiPreferences.dateFormat(context.appGraph.uiPreferences.dateFormat.get()) }
val dateFormat = remember {
UiPreferences.dateFormat(
context.appGraph.uiPreferences.dateFormat.get(),
locale.platformLocale,
)
}
val state by viewModel.state.collectAsState()
TrackInfoDialogHome(
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.more
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -23,6 +22,7 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.MoreScreen
import eu.kanade.presentation.util.Tab
import eu.kanade.presentation.util.rememberAnimatedVectorPainter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -45,11 +45,12 @@ data object MoreTab : Tab {
@Composable
get() {
val isSelected = LocalTabNavigator.current.current.key == key
val image = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more)
val enter = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_enter)
val exit = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_exit)
return TabOptions(
index = 4u,
title = stringResource(MR.strings.label_more),
icon = rememberAnimatedVectorPainter(image, isSelected),
icon = rememberAnimatedVectorPainter(enter, exit, isSelected),
)
}
@@ -1,54 +1,75 @@
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 com.google.android.material.transition.platform.MaterialContainerTransform
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
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
@@ -58,7 +79,6 @@ import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
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
@@ -72,9 +92,10 @@ 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.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
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.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
@@ -82,14 +103,13 @@ 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.combine
import kotlinx.coroutines.flow.MutableStateFlow
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
@@ -98,8 +118,8 @@ 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 kotlin.time.Duration.Companion.seconds
class ReaderActivity : BaseActivity() {
@@ -119,23 +139,26 @@ 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 loadingIndicator: ReaderProgressIndicator? = null
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
var isScrollingThroughPages = false
private set
@@ -165,9 +188,140 @@ class ReaderActivity : BaseActivity() {
super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.setComposeOverlay()
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 -> {}
}
}
if (!viewModel.hasValidArgs) {
finish()
@@ -180,7 +334,6 @@ class ReaderActivity : BaseActivity() {
Notifications.ID_NEW_CHAPTERS,
)
config = ReaderConfig()
setMenuVisibility(viewModel.state.value.menuVisible)
// Finish when incognito mode is disabled
@@ -196,12 +349,6 @@ class ReaderActivity : BaseActivity() {
.onEach(::setInitialChapterError)
.launchIn(lifecycleScope)
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
.onEach(::setProgressDialog)
.launchIn(lifecycleScope)
viewModel.state
.map { it.manga }
.distinctUntilChanged()
@@ -245,100 +392,12 @@ 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, configuration and any view.
* Called when the activity is destroyed. Cleans up the viewer and any view.
*/
override fun onDestroy() {
super.onDestroy()
viewModel.state.value.viewer?.destroy()
config = null
viewer?.destroy()
menuToggleToast?.cancel()
readingModeToast?.cancel()
}
@@ -393,59 +452,97 @@ 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 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
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)
}
ReaderContentOverlay(
brightness = state.brightnessOverlayValue,
color = colorOverlay.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayBlendMode,
)
// 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),
)
}
}
}
if (flashOnPageChange) {
DisplayRefreshHost(hostState = displayRefreshHost)
/**
* 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()
}
}
@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()
@@ -453,9 +550,7 @@ class ReaderActivity : BaseActivity() {
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState()
val verticalNavigator = verticalNavigatorModes.contains(
ReadingMode.fromPreference(viewModel.getMangaReadingMode()),
)
val verticalNavigator = verticalNavigatorModes.contains(readingMode)
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState()
@@ -473,7 +568,7 @@ class ReaderActivity : BaseActivity() {
onShare = ::shareChapter.takeIf { isHttpSource },
chapterNavigatorType = if (!verticalNavigator) {
if (state.viewer is R2LPagerViewer || (state.viewer as? WebGpuViewer)?.isReversed ?: false) {
if (readingMode == ReadingMode.RIGHT_TO_LEFT) {
ChapterNavigatorType.HORIZONTAL_RTL
} else {
ChapterNavigatorType.HORIZONTAL_LTR
@@ -534,35 +629,22 @@ class ReaderActivity : BaseActivity() {
* Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer.
*/
private fun updateViewer() {
val prevViewer = viewModel.state.value.viewer
val newViewer = ReadingMode.toViewer(viewModel.getMangaReadingMode(), this)
val prevViewer = viewer
val readingMode = viewModel.getMangaReadingMode()
settingsViewModel.setViewerReadingMode(ReadingMode.fromPreference(readingMode))
val newViewer = ReadingMode.toViewer(readingMode, this)
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())
}
setOrientation(viewModel.getMangaOrientation())
// Destroy previous viewer if there was one
if (prevViewer != null) {
prevViewer.destroy()
binding.viewerContainer.removeAllViews()
}
viewModel.onViewerLoaded(newViewer)
updateViewerInset(readerPreferences.fullscreen.get(), readerPreferences.drawUnderCutout.get())
binding.viewerContainer.addView(newViewer.getView())
prevViewer?.destroy()
viewerFlow.value = newViewer
if (readerPreferences.showReadingMode.get()) {
showReadingModeToast(viewModel.getMangaReadingMode())
}
loadingIndicator = ReaderProgressIndicator(this)
binding.readerContainer.addView(loadingIndicator)
startPostponedEnterTransition()
isLoadingViewer = true
}
private fun openMangaScreen() {
@@ -613,10 +695,9 @@ 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) {
binding.readerContainer.removeView(loadingIndicator)
viewModel.state.value.viewer?.setChapters(viewerChapters)
isLoadingViewer = false
viewer?.setChapters(viewerChapters)
lifecycleScope.launch(Dispatchers.IO) {
viewModel.getChapterUrl()?.let { url ->
@@ -635,26 +716,12 @@ 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 = viewModel.state.value.viewer ?: return
val viewer = this.viewer ?: return
val currentChapter = viewModel.state.value.currentChapter ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return
viewer.moveToPage(page)
@@ -793,149 +860,41 @@ class ReaderActivity : BaseActivity() {
}
/**
* Updates viewer inset depending on fullscreen reader preferences.
* 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.
*/
private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
if (!::binding.isInitialized) return
val view = binding.viewerContainer
view.applyInsetsPadding(ViewCompat.getRootWindowInsets(view), fullscreen, drawUnderCutout)
ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
view.applyInsetsPadding(windowInsets, fullscreen, drawUnderCutout)
windowInsets
}
}
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,
),
),
)
}
},
)
private fun setWindowBrightness(value: Int) {
// Calculate and set reader brightness.
val readerBrightness = when {
value > 0 -> {
value / 100f
}
}
/*
* Initializes the reader subscriptions.
*/
init {
readerPreferences.readerTheme.changes()
.onEach { theme ->
binding.readerContainer.setBackgroundColor(baseContext.readerBackgroundColor(theme))
}
.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 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)
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
/**
* 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)
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
}
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,
),
)
}
if (grayscale) {
matrix *= ColorMatrix().apply { setToSaturation(0f) }
}
return ColorFilter.colorMatrix(matrix)
}
@@ -1,113 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.graphics.withScale
import androidx.core.graphics.withTranslation
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import 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,7 +2,6 @@ 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
@@ -25,7 +24,6 @@ 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
@@ -47,7 +45,6 @@ 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
@@ -78,6 +75,7 @@ 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
@@ -527,12 +525,6 @@ 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
@@ -920,10 +912,6 @@ 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) }
}
@@ -944,10 +932,6 @@ 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.
@@ -1110,14 +1094,8 @@ 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
@@ -1127,7 +1105,6 @@ class ReaderViewModel(
}
sealed interface Dialog {
data object Loading : Dialog
data object Settings : Dialog
data object ReadingModeSelect : Dialog
data object OrientationModeSelect : Dialog
@@ -2,7 +2,6 @@ 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
@@ -13,6 +12,7 @@ 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,7 +1,6 @@
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
@@ -16,6 +15,8 @@ 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
/**
@@ -41,14 +42,8 @@ internal class HttpPageLoader(
* otherwise fallbacks to network.
*/
override suspend fun getPages(): List<ReaderPage> {
val pages = try {
chapterCache.getPageListFromCache(chapter.chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
source.getPageList(chapter.chapter.toSChapter())
}
val pages = chapterCache.getPageList(chapter.chapter)
?: 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 {
@@ -114,7 +109,7 @@ internal class HttpPageLoader(
try {
// Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
chapterCache.putPageList(chapter.chapter, pagesToSave)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
@@ -230,13 +225,12 @@ internal class HttpPageLoader(
}
val imageUrl = page.imageUrl!!
if (force || !chapterCache.isImageInCache(imageUrl)) {
val file = chapterCache.getImageFile(imageUrl).takeUnless { force } ?: run {
val live = page.downloadStream
page.status = Page.State.DownloadImage
try {
val imageResponse = source.getImage(page)
chapterCache.putImageToCache(imageUrl, imageResponse, live)
live?.finish()
chapterCache.putImage(imageUrl, source.getImage(page), live)
.also { live?.finish() }
} catch (e: Throwable) {
live?.finish(e)
throw e
@@ -244,7 +238,16 @@ internal class HttpPageLoader(
}
page.downloadStream = null
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
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.status = Page.State.Ready
} catch (e: Throwable) {
page.downloadStream = null
@@ -0,0 +1,23 @@
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,6 +6,8 @@ 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
@@ -220,6 +222,25 @@ class ReaderPreferences(
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 {
@@ -245,6 +266,70 @@ class ReaderPreferences(
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),
@@ -2,27 +2,317 @@ 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(
readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit,
private val readerViewModel: ReaderViewModel,
val preferences: ReaderPreferences,
private val basePreferences: BasePreferences,
) : ViewModel() {
val viewerFlow = readerState
.map { it.viewer }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val mangaFlow = readerState
val mangaFlow = readerViewModel.state
.map { it.manga }
.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,
)
}
@@ -0,0 +1,11 @@
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
}
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* Settings every long strip viewer uses.
*/
data class LongStripSettings(
val cropBorders: Boolean,
val zoomOutDisabled: Boolean,
)
@@ -0,0 +1,89 @@
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)
}
@@ -0,0 +1,23 @@
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,
)
}
@@ -1,29 +0,0 @@
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)
}
}
@@ -0,0 +1,78 @@
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,6 +1,8 @@
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
@@ -13,6 +15,7 @@ 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
@@ -60,6 +63,30 @@ 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
@@ -257,6 +284,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
setOnClickListener { this@ReaderPageImageView.onViewClicked() }
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
}
private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
@@ -315,6 +343,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
pageView = WebtoonStripImageView(context)
addView(pageView, MATCH_PARENT, WRAP_CONTENT)
applyPageColorFilter()
}
private fun setStripImage(data: BufferedSource, config: Config) {
@@ -391,6 +420,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
}
private fun setAnimatedImage(
@@ -0,0 +1,25 @@
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)
}
}
@@ -0,0 +1,86 @@
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,91 +2,58 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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 tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
/**
* [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.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
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
}
fun bind(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit = {},
statusPadding: PaddingValues = PaddingValues(),
) {
data = Data(transition, downloadState, onRetry, onButtonPressChange, statusPadding)
}
@Composable
override fun Content() {
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,
)
}
}
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,
)
}
}
private data class Data(
val transition: ChapterTransition,
val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: Boolean,
val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
val downloadState: TransitionDownloadState?,
val onRetry: (ReaderChapter) -> Unit,
val onButtonPressChange: (pressed: Boolean) -> Unit,
val statusPadding: PaddingValues,
)
}
@@ -0,0 +1,56 @@
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,8 +1,12 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
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 eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@@ -12,9 +16,10 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
interface Viewer {
/**
* Returns the view this viewer uses.
* Draws this viewer.
*/
fun getView(): View
@Composable
fun Content(modifier: Modifier)
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
@@ -32,14 +37,37 @@ interface Viewer {
fun moveToPage(page: ReaderPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
* Moves forward through the pages.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
fun moveForward()
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
* Moves backward through the pages.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
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() }
}
}
}
@@ -1,90 +0,0 @@
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)
}
}
@@ -0,0 +1,43 @@
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,54 +1,75 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.graphics.Color
import android.graphics.PointF
import android.graphics.RectF
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
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
abstract class ViewerNavigation {
/**
* 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,
) {
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))
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))
}
data class Region(
val rectF: RectF,
val rect: Rect,
val type: NavigationRegion,
) {
fun invert(invertMode: ReaderPreferences.TappingInvertMode): Region {
if (invertMode == ReaderPreferences.TappingInvertMode.NONE) return this
return this.copy(
rectF = this.rectF.invert(invertMode),
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,
),
)
}
}
private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
var invertMode: ReaderPreferences.TappingInvertMode = ReaderPreferences.TappingInvertMode.NONE
protected abstract var regionList: List<Region>
protected abstract val regionList: List<Region>
/** Returns regions with applied inversion. */
fun getRegions(): List<Region> {
return regionList.map { it.invert(invertMode) }
}
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 getAction(pos: Offset): Action {
val region = getRegions().find { pos in it.rect } ?: return Action.MENU
return actionOf(region.type)
}
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()
}
@@ -0,0 +1,61 @@
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,5 +1,6 @@
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
/**
@@ -12,7 +13,10 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | M | M | M | N: Next
* +---+---+---+
*/
class DisabledNavigation : ViewerNavigation() {
class DisabledNavigation(
invertMode: TappingInvertMode,
isReversed: Boolean,
) : ViewerNavigation(invertMode, isReversed) {
override var regionList: List<Region> = emptyList()
override val regionList: List<Region> = emptyList()
}
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,19 +14,19 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | P | N | N: Next
* +---+---+---+
*/
class EdgeNavigation : ViewerNavigation() {
class EdgeNavigation(invertMode: TappingInvertMode, isReversed: Boolean) : ViewerNavigation(invertMode, isReversed) {
override var regionList: List<Region> = listOf(
override val regionList: List<Region> = listOf(
Region(
rectF = RectF(0f, 0f, 0.33f, 1f),
rect = Rect(0f, 0f, 0.33f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rectF = RectF(0.33f, 0.66f, 0.66f, 1f),
rect = Rect(0.33f, 0.66f, 0.66f, 1f),
type = NavigationRegion.PREV,
),
Region(
rectF = RectF(0.66f, 0f, 1f, 1f),
rect = Rect(0.66f, 0f, 1f, 1f),
type = NavigationRegion.NEXT,
),
)
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,15 +14,18 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | P | N | N | N: Next
* +---+---+---+
*/
class KindlishNavigation : ViewerNavigation() {
class KindlishNavigation(
invertMode: TappingInvertMode,
isReversed: Boolean,
) : ViewerNavigation(invertMode, isReversed) {
override var regionList: List<Region> = listOf(
override val regionList: List<Region> = listOf(
Region(
rectF = RectF(0.33f, 0.33f, 1f, 1f),
rect = Rect(0.33f, 0.33f, 1f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rectF = RectF(0f, 0.33f, 0.33f, 1f),
rect = Rect(0f, 0.33f, 0.33f, 1f),
type = NavigationRegion.PREV,
),
)

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