mirror of
https://github.com/mihonapp/mihon.git
synced 2026-11-03 13:35:45 -08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e588e04359 | ||
|
|
9079ad8c9a | ||
|
|
00335b2a70 | ||
|
|
7a672e9c6b | ||
|
|
96d904a5fc | ||
|
|
27994263da | ||
|
|
8124d324a1 | ||
|
|
ca028cb657 | ||
|
|
ced89e3ae1 | ||
|
|
cfeccaf08b | ||
|
|
0606634750 | ||
|
|
007ec9a868 | ||
|
|
b19c8abeba | ||
|
|
e8977b4a66 | ||
|
|
cdc7d69c92 | ||
|
|
3ed83a8d32 | ||
|
|
bd2ada2e82 | ||
|
|
7a5632c013 | ||
|
|
4310a224d4 | ||
|
|
ab5f6585bc | ||
|
|
e48fc8711f | ||
|
|
c8634ed089 | ||
|
|
8b8ee26af3 | ||
|
|
d001e6f210 | ||
|
|
2ca6961e1c | ||
|
|
0864ce0f46 | ||
|
|
487c313d31 | ||
|
|
4493166985 | ||
|
|
b8070cdc9c | ||
|
|
5432ae74be | ||
|
|
90f6c8dcff | ||
|
|
a424282840 | ||
|
|
8b9faa383b | ||
|
|
4750748dd1 | ||
|
|
6492feebe8 | ||
|
|
1ff3a47e02 | ||
|
|
18534bf477 | ||
|
+1 |
2e72b50f52 | ||
|
|
deeb9b2584 | ||
|
|
a01867dc3e | ||
|
|
5f63c983da | ||
|
|
83e8f917dc | ||
|
|
3058849b52 | ||
|
|
970b12f1bd | ||
|
+19 |
7618df898c | ||
|
|
446f6df82c | ||
|
|
3100dceddd | ||
|
|
771ba33a33 | ||
|
|
5a5ff22c37 | ||
|
|
f0f1e4de5a | ||
|
|
c371d6331d | ||
|
|
2df48e19a1 | ||
|
|
c9e1b6e96e | ||
|
|
3156b4a1c4 | ||
|
|
ee59394542 | ||
|
|
7ae858e602 | ||
|
|
43de88c718 | ||
|
|
ea2b73ad78 | ||
|
|
1ca9b0efb8 | ||
|
|
66e736222d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
@@ -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()) },
|
||||
|
||||
+22
-4
@@ -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) {
|
||||
|
||||
-5
@@ -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) {
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+50
-18
@@ -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}")
|
||||
}
|
||||
|
||||
+10
-3
@@ -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
|
||||
|
||||
+35
@@ -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) },
|
||||
)
|
||||
}
|
||||
+41
-14
@@ -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
|
||||
|
||||
+54
-30
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -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> {
|
||||
|
||||
+50
-39
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -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()
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
+292
-113
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-5
@@ -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),
|
||||
|
||||
+299
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+9
-5
@@ -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
Reference in New Issue
Block a user