Compare commits

..
5 Commits
Author SHA1 Message Date
AntsyLich e9670d8a12 Release v0.21.1 2026-10-12 14:09:01 +06:00
AntsyLich 87e808548b Merge branch 'main' into 0.21.0
# Conflicts:
#	CHANGELOG.md
2026-10-12 14:04:00 +06:00
AntsyLich 79fc3a0a8a Release v0.21.0 2026-10-12 01:37:06 +06:00
AntsyLich c8b820a08f Regenerate baseline profiles 2026-10-12 01:37:05 +06:00
AntsyLich f45d0ad31d Remove the WebGPU reader and go back to the old image decoder
Brings back SSIV and the image decoder from v0.20.4 with their settings,
except "Use legacy decoder for long strip reader", which long strip
pages drawn as strips replace.
2026-10-12 01:14:22 +06:00
257 changed files with 4665 additions and 9761 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true required: true
- label: I have written a short but informative title. - label: I have written a short but informative title.
required: true required: true
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**. - label: I have updated the app to version **[0.21.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version label: Mihon version
description: You can find your Mihon version in **More → About**. description: You can find your Mihon version in **More → About**.
placeholder: | placeholder: |
Example: "0.21.3" Example: "0.21.1"
validations: validations:
required: true required: true
@@ -95,7 +95,7 @@ body:
required: true required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/). - label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true required: true
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**. - label: I have updated the app to version **[0.21.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true required: true
- label: I have filled out all of the requested information in this form, including specific version numbers. - label: I have filled out all of the requested information in this form, including specific version numbers.
required: true required: true
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
java-version-file: .github/.java-version java-version-file: .github/.java-version
distribution: temurin distribution: temurin
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
java-version-file: .github/.java-version java-version-file: .github/.java-version
distribution: temurin distribution: temurin
@@ -90,7 +90,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with: with:
java-version-file: .github/.java-version java-version-file: .github/.java-version
distribution: temurin distribution: temurin
+1 -67
View File
@@ -11,70 +11,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [Unreleased]
### Added
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
### Changed
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
- Keep online chapters cached for a day instead of up to a size limit ([@AntsyLich](https://github.com/AntsyLich))
- Apply the reader's grayscale and inverted colors to pages only, not to transition, loading and error pages ([@AntsyLich](https://github.com/AntsyLich))
### Removed
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
### Fixed
- Fix extension settings showing duplicate entries after the app is restored from the background ([@AntsyLich](https://github.com/AntsyLich))
## [v0.21.3] - 2026-10-26
### Added
- 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 ## [v0.21.1] - 2026-10-12
### Fixed ### Fixed
@@ -836,9 +772,7 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich)) - 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)) - 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.3...main [unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.1...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.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.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.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
+4 -5
View File
@@ -37,8 +37,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "app.mihon" applicationId = "app.mihon"
versionCode = 39 versionCode = 37
versionName = "0.21.3" versionName = "0.21.1"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}") buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}") buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -229,7 +229,6 @@ dependencies {
debugImplementation(libs.androidx.compose.uiTooling) debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview) implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil) implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator) implementation(libs.androidx.interpolator)
@@ -273,6 +272,7 @@ dependencies {
implementation(libs.jsoup) implementation(libs.jsoup)
// Disk // Disk
implementation(libs.diskLruCache)
implementation(libs.unifile) implementation(libs.unifile)
// Preferences // Preferences
@@ -291,10 +291,9 @@ dependencies {
} }
implementation(libs.image.decoder) implementation(libs.image.decoder)
implementation(libs.webgpuviewer)
// UI libraries // UI libraries
implementation(libs.material) implementation(libs.material)
implementation(libs.flexibleAdapter)
implementation(libs.photoView) implementation(libs.photoView)
implementation(libs.directionalViewPager) { implementation(libs.directionalViewPager) {
exclude(group = "androidx.viewpager", module = "viewpager") exclude(group = "androidx.viewpager", module = "viewpager")
@@ -59,6 +59,30 @@ inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
return fastFilter { !predicate(it) } 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]. * Returns the number of entries not matching the given [predicate].
* *
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.util.system.GLUtil
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -37,7 +38,12 @@ class BasePreferences(
PRIVATE(MR.strings.ext_installer_private, false), PRIVATE(MR.strings.ext_installer_private, false),
} }
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false) val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
"pref_hardware_bitmap_threshold",
GLUtil.SAFE_TEXTURE_LIMIT,
)
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "") val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
@@ -8,8 +8,10 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -59,7 +61,10 @@ class AddTracks(
.minOrNull() .minOrNull()
firstReadChapterDate?.let { firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds() val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
track = track.copy( track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate), startDate = Instant.fromEpochMilliseconds(startDate),
) )
@@ -1,22 +1,17 @@
package eu.kanade.domain.ui package eu.kanade.domain.ui
import android.text.format.DateFormat
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.ui.model.AppTheme import eu.kanade.domain.ui.model.AppTheme
import eu.kanade.domain.ui.model.TabletUiMode import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode 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.DeviceUtil
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
import logcat.LogPriority
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.util.system.logcat
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
@@ -43,53 +38,14 @@ class UiPreferences(
val dateFormat: Preference<String> = preferenceStore.getString("app_date_format", "") 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 tabletUiMode: Preference<TabletUiMode> = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)
val imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true) val imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true)
companion object { companion object {
fun dateFormat(format: String, locale: Locale): DateTimeFormatter = when (format) { fun dateFormat(format: String): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale) "" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
else -> DateTimeFormatter.ofPattern(format, locale) else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
}
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)
} }
} }
} }
@@ -1,11 +0,0 @@
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,7 +3,6 @@ package eu.kanade.presentation.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.util.lang.toRelativeString import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -28,11 +27,10 @@ fun relativeDateText(
localDate: LocalDate?, localDate: LocalDate?,
): String { ): String {
val context = LocalContext.current val context = LocalContext.current
val locale = LocalLocale.current
val preferences = remember { context.appGraph.uiPreferences } val preferences = remember { context.appGraph.uiPreferences }
val relativeTime = remember { preferences.relativeTime.get() } val relativeTime = remember { preferences.relativeTime.get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get(), locale.platformLocale) } val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get()) }
return localDate?.toRelativeString( return localDate?.toRelativeString(
context = context, context = context,
@@ -1,494 +0,0 @@
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,14 +6,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.AppBarTitle import eu.kanade.presentation.components.AppBarTitle
@@ -21,10 +16,8 @@ import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.history.components.HistoryItem import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.ui.history.HistoryViewModel import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.DeleteSweep import mihon.icons.materialsymbols.rounded.DeleteSweep
import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.model.HistoryWithRelations
@@ -35,7 +28,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
@@ -107,14 +99,6 @@ private fun HistoryScreenContent(
onClickDelete: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations) -> Unit,
onClickFavorite: (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( FastScrollLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
@@ -140,7 +124,6 @@ private fun HistoryScreenContent(
HistoryItem( HistoryItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
history = value, history = value,
timeFormatter = timeFormatter,
onClickCover = { onClickCover(value) }, onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) }, onClickResume = { onClickResume(value) },
onClickDelete = { onClickDelete(value) }, onClickDelete = { onClickDelete(value) },
@@ -31,15 +31,12 @@ import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
private val HistoryItemHeight = 96.dp private val HistoryItemHeight = 96.dp
@Composable @Composable
fun HistoryItem( fun HistoryItem(
history: HistoryWithRelations, history: HistoryWithRelations,
timeFormatter: DateTimeFormatter,
onClickCover: () -> Unit, onClickCover: () -> Unit,
onClickResume: () -> Unit, onClickResume: () -> Unit,
onClickDelete: () -> Unit, onClickDelete: () -> Unit,
@@ -71,9 +68,7 @@ fun HistoryItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = textStyle, style = textStyle,
) )
val readAt = remember(timeFormatter, history.readAt) { val readAt = remember { history.readAt?.toTimestampString() ?: "" }
history.readAt?.toTimestampString(timeFormatter) ?: ""
}
Text( Text(
text = if (history.chapterNumber > -1) { text = if (history.chapterNumber > -1) {
stringResource( stringResource(
@@ -119,7 +114,6 @@ private fun HistoryItemPreviews(
Surface { Surface {
HistoryItem( HistoryItem(
history = historyWithRelations, history = historyWithRelations,
timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT),
onClickCover = {}, onClickCover = {},
onClickResume = {}, onClickResume = {},
onClickDelete = {}, onClickDelete = {},
@@ -13,3 +13,13 @@ fun DotSeparatorText(
modifier = modifier, modifier = modifier,
) )
} }
@Composable
fun DotSeparatorNoSpaceText(
modifier: Modifier = Modifier,
) {
Text(
text = "•",
modifier = modifier,
)
}
@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import ca.mpreg.webgpuviewer.renderer.GainmapInput
import ca.mpreg.webgpuviewer.renderer.Image
import ca.mpreg.webgpuviewer.viewer.ImagePage
import ca.mpreg.webgpuviewer.viewer.ImageViewer
import ca.mpreg.webgpuviewer.viewer.ImageViewerState
import coil3.asDrawable import coil3.asDrawable
import coil3.imageLoader import coil3.imageLoader
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Size import coil3.size.Size
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.newDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Edit import mihon.icons.materialsymbols.rounded.Edit
@@ -79,9 +65,6 @@ fun MangaCoverDialog(
onEditClick: ((EditCoverAction) -> Unit)?, onEditClick: ((EditCoverAction) -> Unit)?,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get()
val view = LocalView.current
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = DialogProperties( properties = DialogProperties(
@@ -168,59 +151,6 @@ fun MangaCoverDialog(
} }
}, },
) { contentPadding -> ) { contentPadding ->
if (useNewRenderer) {
val state = remember(manga) { ImageViewerState() }
LaunchedEffect(manga) {
val request = ImageRequest.Builder(view.context)
.data(manga)
.size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true)
.build()
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
// Held, then freed, around the upload: its buffer alone doesn't keep
// the frame's native pixels alive.
val page = res.frame.use {
withContext(Dispatchers.Default) {
ImagePage.ImageSingle(
Image(
res.image,
res.width,
res.height,
createMipMaps = true,
backgroundColor = 0,
hdr = res.isHdr,
hdrHeadroom = res.hdrHeadroom,
gainmap = res.gainmap?.let {
GainmapInput(
pixels = it.pixels,
width = it.width,
height = it.height,
channels = it.channels,
gamma = it.gamma,
minContentBoost = it.minContentBoost,
maxContentBoost = it.maxContentBoost,
offsetSdr = it.offsetSdr,
offsetHdr = it.offsetHdr,
)
},
),
)
}
}
state.apply {
fetchPage = { index ->
if (index == 0) page else null
}
invalidate()
}
}
ImageViewer(state = state)
return@Scaffold
}
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
@@ -105,8 +105,6 @@ private fun getMarkdownColors(): MarkdownColors {
inlineCodeBackground = codeBackground, inlineCodeBackground = codeBackground,
dividerColor = MaterialTheme.colorScheme.outlineVariant, dividerColor = MaterialTheme.colorScheme.outlineVariant,
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
highlightText = MaterialTheme.colorScheme.onSecondaryContainer,
highlightBackground = MaterialTheme.colorScheme.secondaryContainer,
) )
} }
@@ -144,6 +144,20 @@ internal class PermissionStep : OnboardingStep {
} }
} }
@Composable
private fun SectionHeader(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = modifier
.padding(horizontal = 16.dp)
.secondaryItemAlpha(),
)
}
@Composable @Composable
private fun PermissionCheckbox( private fun PermissionCheckbox(
title: String, title: String,
@@ -6,6 +6,8 @@ import android.content.Intent
import android.provider.Settings import android.provider.Settings
import android.webkit.WebStorage import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
import eu.kanade.tachiyomi.ui.more.OnboardingScreen import eu.kanade.tachiyomi.ui.more.OnboardingScreen
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.isShizukuInstalled
import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.powerManager
@@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import okhttp3.Headers import okhttp3.Headers
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -97,15 +101,6 @@ object SettingsAdvancedScreen : SearchableSettings {
true 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( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_debug_info), title = stringResource(MR.strings.pref_debug_info),
onClick = { navigator.push(DebugInfoScreen()) }, onClick = { navigator.push(DebugInfoScreen()) },
@@ -342,12 +337,43 @@ object SettingsAdvancedScreen : SearchableSettings {
basePreferences: BasePreferences, basePreferences: BasePreferences,
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val chooseColorProfile = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
uri?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
basePreferences.displayProfile.set(uri.toString())
}
}
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reader), title = stringResource(MR.strings.pref_category_reader),
preferenceItems = listOf( preferenceItems = listOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.ListPreference(
preference = basePreferences.highQualityRenderer, preference = basePreferences.hardwareBitmapThreshold,
title = stringResource(MR.strings.pref_high_quality_renderer), entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
.mapIndexed { index, option ->
val display = if (index == 0) {
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
} else {
option.toString()
}
option to display
}
.toMap(),
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
subtitleProvider = { value, options ->
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
},
visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_display_profile),
subtitle = basePreferences.displayProfile.get(),
onClick = {
chooseColorProfile.launch(arrayOf("*/*"))
},
), ),
), ),
) )
@@ -7,20 +7,17 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.TabletUiMode import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode import eu.kanade.domain.ui.model.ThemeMode
import eu.kanade.domain.ui.model.TimeFormat
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
@@ -104,14 +101,12 @@ object SettingsAppearanceScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val locale = LocalLocale.current
val is24HourSystem = rememberIs24HourSystem()
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() } val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
val dateFormat by uiPreferences.dateFormat.collectAsState() val dateFormat by uiPreferences.dateFormat.collectAsState()
val formattedDateNow = remember(dateFormat) { val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat, locale.platformLocale).format(now) UiPreferences.dateFormat(dateFormat).format(now)
} }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
@@ -135,31 +130,18 @@ object SettingsAppearanceScreen : SearchableSettings {
preference = uiPreferences.dateFormat, preference = uiPreferences.dateFormat,
entries = DateFormats entries = DateFormats
.associateWith { .associateWith {
val formattedDate = UiPreferences.dateFormat(it, locale.platformLocale).format(now) val formattedDate = UiPreferences.dateFormat(it).format(now)
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)" "${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
}, },
title = stringResource(MR.strings.pref_date_format), 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.PreferenceItem.SwitchPreference(
preference = uiPreferences.relativeTime, preference = uiPreferences.relativeTime,
title = stringResource(MR.strings.pref_relative_format), title = stringResource(MR.strings.pref_relative_format),
subtitle = stringResource( subtitle = stringResource(
MR.strings.pref_relative_format_summary, MR.strings.pref_relative_format_summary,
stringResource(MR.strings.relative_time_today), stringResource(MR.strings.relative_time_today),
formattedDateNow, formattedNow,
), ),
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
@@ -286,7 +287,7 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { context.appGraph.chapterCache } val chapterCache = remember { context.appGraph.chapterCache }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) } var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize by produceState("", cacheReadableSizeSema) { val cacheReadableSize by produceState("", cacheReadableSizeSema) {
value = chapterCache.readableSize() value = withContext(Dispatchers.IO) { chapterCache.readableSize }
} }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
@@ -310,7 +311,7 @@ object SettingsDataScreen : SearchableSettings {
onClick = { onClick = {
scope.launch { scope.launch {
try { try {
val deletedFiles = chapterCache.clear() val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles)) context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
cacheReadableSizeSema++ cacheReadableSizeSema++
} catch (e: Exception) { } catch (e: Exception) {
@@ -43,6 +43,11 @@ object SettingsDownloadScreen : SearchableSettings {
preference = downloadPreferences.saveChaptersAsCBZ, preference = downloadPreferences.saveChaptersAsCBZ,
title = stringResource(MR.strings.save_chapter_as_cbz), 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.PreferenceItem.SliderPreference(
preference = downloadPreferences.parallelSourceLimit, preference = downloadPreferences.parallelSourceLimit,
valueRange = 1..10, valueRange = 1..10,
@@ -1,7 +1,6 @@
package eu.kanade.presentation.more.settings.screen.about package eu.kanade.presentation.more.settings.screen.about
import android.content.Context import android.content.Context
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -28,7 +27,6 @@ import eu.kanade.presentation.more.LogoHeader
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.util.LocalBackPress import eu.kanade.presentation.util.LocalBackPress
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.RELEASE_URL import eu.kanade.tachiyomi.data.updater.RELEASE_URL
import eu.kanade.tachiyomi.ui.more.NewUpdateScreen import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
@@ -63,7 +61,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.Locale
import kotlin.time.Instant import kotlin.time.Instant
object AboutScreen : Screen() { object AboutScreen : Screen() {
@@ -77,7 +74,6 @@ object AboutScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
var isCheckingUpdates by remember { mutableStateOf(false) } var isCheckingUpdates by remember { mutableStateOf(false) }
val crashLogUtil = remember { context.appGraph.crashLogUtil } val crashLogUtil = remember { context.appGraph.crashLogUtil }
val is24HourSystem = rememberIs24HourSystem()
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
@@ -100,9 +96,7 @@ object AboutScreen : Screen() {
item { item {
TextPreferenceWidget( TextPreferenceWidget(
title = stringResource(MR.strings.version), title = stringResource(MR.strings.version),
// Keyed on the system 12/24h setting, which can change while the app is backgrounded. subtitle = getVersionName(withBuildDate = true),
// Date/time prefs only change in Appearance, which recreates this screen on return.
subtitle = remember(is24HourSystem) { getVersionName(withBuildDate = true) },
onPreferenceClick = { onPreferenceClick = {
val deviceInfo = crashLogUtil.getDebugInfo() val deviceInfo = crashLogUtil.getDebugInfo()
context.copyToClipboard("Debug information", deviceInfo) context.copyToClipboard("Debug information", deviceInfo)
@@ -281,16 +275,11 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String { internal fun getFormattedBuildTime(): String {
return try { return try {
val context = Injekt.get<Context>()
val uiPreferences = context.appGraph.uiPreferences
Instant.parse(BuildConfig.BUILD_TIME) Instant.parse(BuildConfig.BUILD_TIME)
.toLocalDateTime(TimeZone.currentSystemDefault()) .toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString( .toDateTimestampString(
UiPreferences.dateTimeFormat( UiPreferences.dateFormat(
uiPreferences.dateFormat.get(), Injekt.get<Context>().appGraph.uiPreferences.dateFormat.get(),
uiPreferences.timeFormat.get(),
Locale.getDefault(),
DateFormat.is24HourFormat(context),
), ),
) )
} catch (_: Exception) { } catch (_: Exception) {
@@ -18,7 +18,6 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.PreferenceScaffold import eu.kanade.presentation.more.settings.PreferenceScaffold
import eu.kanade.presentation.more.settings.screen.about.AboutScreen import eu.kanade.presentation.more.settings.screen.about.AboutScreen
import eu.kanade.presentation.util.Screen 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.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
@@ -60,7 +59,6 @@ class DebugInfoScreen : Screen() {
private fun getAppInfoGroup(): Preference.PreferenceGroup { private fun getAppInfoGroup(): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val is24HourSystem = rememberIs24HourSystem()
val installationIdPref = remember { context.appGraph.basePreferences.installationId } val installationIdPref = remember { context.appGraph.basePreferences.installationId }
val installationId by installationIdPref.collectAsState() val installationId by installationIdPref.collectAsState()
@@ -74,9 +72,7 @@ class DebugInfoScreen : Screen() {
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Build time", title = "Build time",
// Keyed on the system 12/24h setting, which can change while the app is backgrounded. subtitle = AboutScreen.getFormattedBuildTime(),
// Date/time prefs only change in Appearance, which recreates this screen on return.
subtitle = remember(is24HourSystem) { AboutScreen.getFormattedBuildTime() },
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Installation ID", title = "Installation ID",
@@ -1,7 +1,6 @@
package eu.kanade.presentation.more.settings.screen.debug package eu.kanade.presentation.more.settings.screen.debug
import android.content.Context import android.content.Context
import android.text.format.DateFormat
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -10,7 +9,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -33,13 +31,11 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.lang.toDateTimestampString import eu.kanade.tachiyomi.util.lang.toDateTimestampString
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -50,8 +46,6 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.time.Instant import kotlin.time.Instant
class WorkerInfoScreen : Screen() { class WorkerInfoScreen : Screen() {
@@ -66,12 +60,6 @@ class WorkerInfoScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel = metroViewModel<WorkerInfoViewModel>() 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 enqueued by viewModel.enqueued.collectAsState()
val finished by viewModel.finished.collectAsState() val finished by viewModel.finished.collectAsState()
val running by viewModel.running.collectAsState() val running by viewModel.running.collectAsState()
@@ -136,53 +124,29 @@ class WorkerInfoScreen : Screen() {
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class) @ContributesIntoMap(AppScope::class)
class WorkerInfoViewModel( class WorkerInfoViewModel(
context: Context, private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val workManager = context.workManager 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))
private val dateTimeFormatter = combine( val finished = workManager
dateFormatPref.changes(), .getWorkInfosFlow(
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), WorkQuery.fromStates(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED),
), )
dateTimeFormatter, .map(::constructString)
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val running = combine( val running = workManager
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)), .getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
dateTimeFormatter, .map(::constructString)
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val enqueued = combine( val enqueued = workManager
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)), .getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
dateTimeFormatter, .map(::constructString)
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
private fun constructString( private fun constructString(list: List<WorkInfo>) = buildString {
list: List<WorkInfo>,
dateTimeFormatter: DateTimeFormatter,
) = buildString {
if (list.isEmpty()) { if (list.isEmpty()) {
appendLine("-") appendLine("-")
} else { } else {
@@ -196,7 +160,11 @@ class WorkerInfoScreen : Screen() {
if (workInfo.state == WorkInfo.State.ENQUEUED) { if (workInfo.state == WorkInfo.State.ENQUEUED) {
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis) val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
.toLocalDateTime(TimeZone.currentSystemDefault()) .toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(dateTimeFormatter) .toDateTimestampString(
UiPreferences.dateFormat(
context.appGraph.uiPreferences.dateFormat.get(),
),
)
appendLine("Next scheduled run: $timestamp") appendLine("Next scheduled run: $timestamp")
appendLine("Attempt #${workInfo.runAttemptCount + 1}") appendLine("Attempt #${workInfo.runAttemptCount + 1}")
} }
@@ -25,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -43,32 +42,27 @@ internal fun BasePreferenceWidget(
icon: @Composable (() -> Unit)? = null, icon: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
widget: @Composable (() -> Unit)? = null, widget: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
) { ) {
val contentAlpha = if (enabled) 1f else DisabledAlpha
val highlighted = LocalPreferenceHighlighted.current val highlighted = LocalPreferenceHighlighted.current
val minHeight = LocalPreferenceMinHeight.current val minHeight = LocalPreferenceMinHeight.current
Row( Row(
modifier = modifier modifier = modifier
.highlightBackground(highlighted) .highlightBackground(highlighted)
.sizeIn(minHeight = minHeight) .sizeIn(minHeight = minHeight)
.clickable(enabled = enabled && onClick != null, onClick = { onClick?.invoke() }) .clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (icon != null) { if (icon != null) {
Box( Box(
modifier = Modifier modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
.padding(start = PrefsHorizontalPadding, end = 8.dp)
.alpha(contentAlpha),
content = { icon() }, content = { icon() },
) )
} }
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = PrefsVerticalPadding) .padding(vertical = PrefsVerticalPadding),
.alpha(contentAlpha),
) { ) {
if (!title.isNullOrBlank()) { if (!title.isNullOrBlank()) {
Text( Text(
@@ -129,4 +123,3 @@ internal val TrailingWidgetBuffer = 16.dp
internal val PrefsHorizontalPadding = 16.dp internal val PrefsHorizontalPadding = 16.dp
internal val PrefsVerticalPadding = 16.dp internal val PrefsVerticalPadding = 16.dp
internal val TitleFontSize = 16.sp internal val TitleFontSize = 16.sp
internal val DisabledAlpha = 0.38f
@@ -1,35 +0,0 @@
package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun CheckboxPreferenceWidget(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
modifier = modifier,
title = title,
subtitle = subtitle,
icon = icon,
widget = {
Checkbox(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
enabled = enabled,
onPreferenceClick = { onCheckedChanged(!checked) },
)
}
@@ -1,7 +1,6 @@
package eu.kanade.presentation.more.settings.widget package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -47,32 +46,11 @@ fun <T> ListPreferenceWidget(
) )
if (isDialogShown) { if (isDialogShown) {
ListPreferenceDialog( AlertDialog(
title = title,
value = value,
entries = entries,
onValueChange = onValueChange,
onDismissRequest = { isDialogShown = false }, 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() val state = rememberLazyListState()
ScrollbarLazyColumn(state = state) { ScrollbarLazyColumn(state = state) {
entries.forEach { current -> entries.forEach { current ->
@@ -82,8 +60,8 @@ fun <T> ListPreferenceDialog(
label = current.value, label = current.value,
isSelected = isSelected, isSelected = isSelected,
onSelected = { onSelected = {
onValueChange(current.key) onValueChange(current.key!!)
onDismissRequest() isDialogShown = false
}, },
) )
} }
@@ -92,19 +70,14 @@ fun <T> ListPreferenceDialog(
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
} }
} },
}, confirmButton = {
confirmButton = { TextButton(onClick = { isDialogShown = false }) {
TextButton(onClick = onDismissRequest) { Text(text = stringResource(MR.strings.action_cancel))
Text(text = stringResource(MR.strings.action_cancel)) }
} },
}, )
) }
}
@Composable
internal fun PreferenceDialogMessage(message: String) {
Text(text = message, modifier = Modifier.padding(bottom = 8.dp))
} }
@Composable @Composable
@@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.widget package eu.kanade.presentation.more.settings.widget
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -11,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -37,37 +35,16 @@ fun <T> MultiSelectListPreferenceWidget(
) )
if (isDialogShown) { if (isDialogShown) {
MultiSelectListPreferenceDialog( val selected = remember {
title = title, entries.keys
values = values, .filter { values.contains(it) }
entries = entries, .toMutableStateList()
onValuesChange = onValuesChange, }
AlertDialog(
onDismissRequest = { isDialogShown = false }, 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 -> entries.forEach { current ->
item { item {
val isSelected = selected.contains(current.key) val isSelected = selected.contains(current.key)
@@ -85,26 +62,25 @@ fun <T> MultiSelectListPreferenceDialog(
} }
} }
} }
} },
}, properties = DialogProperties(
properties = DialogProperties( usePlatformDefaultWidth = true,
usePlatformDefaultWidth = true, ),
), confirmButton = {
confirmButton = { TextButton(
TextButton( onClick = {
onClick = { onValuesChange(selected.toMutableSet())
// Values without an entry stay, as they can't be unselected here isDialogShown = false
onValuesChange(values.filterNot { it in entries.keys }.toMutableSet().apply { addAll(selected) }) },
onDismissRequest() ) {
}, Text(text = stringResource(MR.strings.action_ok))
) { }
Text(text = stringResource(MR.strings.action_ok)) },
} dismissButton = {
}, TextButton(onClick = { isDialogShown = false }) {
dismissButton = { Text(text = stringResource(MR.strings.action_cancel))
TextButton(onClick = onDismissRequest) { }
Text(text = stringResource(MR.strings.action_cancel)) },
} )
}, }
)
} }
@@ -19,7 +19,6 @@ fun SwitchPreferenceWidget(
subtitle: String? = null, subtitle: String? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
checked: Boolean = false, checked: Boolean = false,
enabled: Boolean = true,
onCheckedChanged: (Boolean) -> Unit, onCheckedChanged: (Boolean) -> Unit,
) { ) {
TextPreferenceWidget( TextPreferenceWidget(
@@ -31,11 +30,9 @@ fun SwitchPreferenceWidget(
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = null, onCheckedChange = null,
enabled = enabled,
modifier = Modifier.padding(start = TrailingWidgetBuffer), modifier = Modifier.padding(start = TrailingWidgetBuffer),
) )
}, },
enabled = enabled,
onPreferenceClick = { onCheckedChanged(!checked) }, onPreferenceClick = { onCheckedChanged(!checked) },
) )
} }
@@ -24,7 +24,6 @@ fun TextPreferenceWidget(
icon: ImageVector? = null, icon: ImageVector? = null,
iconTint: Color = MaterialTheme.colorScheme.primary, iconTint: Color = MaterialTheme.colorScheme.primary,
widget: @Composable (() -> Unit)? = null, widget: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
onPreferenceClick: (() -> Unit)? = null, onPreferenceClick: (() -> Unit)? = null,
) { ) {
BasePreferenceWidget( BasePreferenceWidget(
@@ -57,7 +56,6 @@ fun TextPreferenceWidget(
}, },
onClick = onPreferenceClick, onClick = onPreferenceClick,
widget = widget, widget = widget,
enabled = enabled,
) )
} }
@@ -39,7 +39,7 @@ fun OrientationSelectDialog(
DialogContent( DialogContent(
orientation = orientation, orientation = orientation,
onChangeOrientation = { onChangeOrientation = {
viewModel.setOrientation(it) viewModel.onChangeOrientation(it)
onChange(it.stringRes) onChange(it.stringRes)
onDismissRequest() onDismissRequest()
}, },
@@ -1,65 +0,0 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
/**
* A page that failed to load or decode: the message, Retry, and Open in WebView when [onOpenInWebView] is given.
*
* [onButtonPressChange] tells a viewer that reads every touch for its own taps when a button is held.
*/
@Composable
fun ReaderError(
message: String?,
onRetry: () -> Unit,
onOpenInWebView: (() -> Unit)?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val buttonModifier = if (onButtonPressChange != null) Modifier.reportPress(onButtonPressChange) else Modifier
Column(
modifier = modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = message ?: stringResource(MR.strings.decode_image_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_retry))
}
if (onOpenInWebView != null) {
Button(onClick = onOpenInWebView, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_open_in_web_view))
}
}
}
}
internal fun Modifier.reportPress(onPressChange: (Boolean) -> Unit) = pointerInput(onPressChange) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
onPressChange(true)
waitForUpOrCancellation(pass = PointerEventPass.Initial)
onPressChange(false)
}
}
@@ -1,93 +0,0 @@
package eu.kanade.presentation.reader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderNavigationOverlay(
state: ReaderNavigationOverlayState,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = state.visible,
modifier = modifier,
enter = fadeIn(tween(FADE_DURATION)),
exit = fadeOut(tween(FADE_DURATION)),
) {
val regions = state.regions
val labels = regions.map { stringResource(it.type.nameRes) }
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val textStyle = remember(density) {
TextStyle(color = Color.White, fontSize = with(density) { 64f.toSp() })
}
val borderStyle = remember(textStyle) {
textStyle.copy(color = Color.Black, drawStyle = Stroke(width = 8f))
}
Canvas(modifier = Modifier.fillMaxSize()) {
regions.forEachIndexed { index, region ->
val rect = region.rect
val topLeft = Offset(size.width * rect.left, size.height * rect.top)
val regionSize = Size(size.width * rect.width, size.height * rect.height)
drawRect(color = region.type.color, topLeft = topLeft, size = regionSize)
val text = textMeasurer.measure(labels[index], textStyle)
val textTopLeft = Offset(
x = topLeft.x + (regionSize.width - text.size.width) / 2,
y = topLeft.y + (regionSize.height - text.size.height) / 2,
)
drawText(textMeasurer, labels[index], textTopLeft, borderStyle)
drawText(text, topLeft = textTopLeft)
}
}
}
}
@Stable
class ReaderNavigationOverlayState {
internal var regions by mutableStateOf(emptyList<ViewerNavigation.Region>())
internal var visible by mutableStateOf(false)
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
regions = navigation.getRegions()
if (visible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
visible = true
}
fun hide() {
visible = false
}
}
private const val FADE_DURATION = 1000
@@ -39,7 +39,7 @@ fun ReadingModeSelectDialog(
DialogContent( DialogContent(
readingMode = readingMode, readingMode = readingMode,
onChangeReadingMode = { onChangeReadingMode = {
viewModel.setReadingMode(it) viewModel.onChangeReadingMode(it)
onChange(it.stringRes) onChange(it.stringRes)
onDismissRequest() onDismissRequest()
}, },
@@ -8,13 +8,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.manga.model.readerOrientation import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode import eu.kanade.domain.manga.model.readingMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerSettings import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem import tachiyomi.presentation.core.components.HeadingItem
@@ -26,20 +28,15 @@ import java.text.NumberFormat
@Composable @Composable
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { 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) HeadingItem(MR.strings.pref_category_for_this_series)
val manga by viewModel.mangaFlow.collectAsState() val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) { SettingsChipRow(MR.strings.pref_category_reading_mode) {
ReadingMode.entries.map { ReadingMode.entries.map {
FilterChip( FilterChip(
selected = it == readingMode, selected = it == readingMode,
onClick = { viewModel.setReadingMode(it) }, onClick = { viewModel.onChangeReadingMode(it) },
label = { Text(stringResource(it.stringRes)) }, label = { Text(stringResource(it.stringRes)) },
) )
} }
@@ -50,14 +47,14 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
ReaderOrientation.entries.map { ReaderOrientation.entries.map {
FilterChip( FilterChip(
selected = it == orientation, selected = it == orientation,
onClick = { viewModel.setOrientation(it) }, onClick = { viewModel.onChangeOrientation(it) },
label = { Text(stringResource(it.stringRes)) }, label = { Text(stringResource(it.stringRes)) },
) )
} }
} }
val resolvedReadingMode by viewModel.readingMode.collectAsState() val viewer by viewModel.viewerFlow.collectAsState()
if (resolvedReadingMode.type is ReadingMode.ViewerType.Webtoon) { if (viewer is WebtoonViewer) {
WebtoonViewerSettings(viewModel) WebtoonViewerSettings(viewModel)
} else { } else {
PagerViewerSettings(viewModel) PagerViewerSettings(viewModel)
@@ -210,7 +207,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel
} }
@Composable @Composable
internal fun TapZonesItems( private fun ColumnScope.TapZonesItems(
selected: Int, selected: Int,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
invertMode: ReaderPreferences.TappingInvertMode, invertMode: ReaderPreferences.TappingInvertMode,
@@ -1,32 +0,0 @@
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,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import cafe.adriel.voyager.core.stack.mutableStateStackOf import cafe.adriel.voyager.core.stack.mutableStateStackOf
import com.kevinnzou.web.AccompanistWebChromeClient import com.kevinnzou.web.AccompanistWebChromeClient
import com.kevinnzou.web.AccompanistWebViewClient import com.kevinnzou.web.AccompanistWebViewClient
@@ -109,8 +108,6 @@ fun WebViewScreenContent(
onDispose { isActive = false } onDispose { isActive = false }
} }
val headersHost = remember { url.toUri().host }
val webClient = remember { val webClient = remember {
object : AccompanistWebViewClient() { object : AccompanistWebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
@@ -150,9 +147,6 @@ fun WebViewScreenContent(
// Ignore intents urls // Ignore intents urls
if (url.startsWith("intent://")) return true 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 // Only open valid web urls
if (url.startsWith("http") || url.startsWith("https")) { if (url.startsWith("http") || url.startsWith("https")) {
if (url != view?.url) { if (url != view?.url) {
@@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
.onEach(TelemetryConfig::setCrashlyticsEnabled) .onEach(TelemetryConfig::setCrashlyticsEnabled)
.launchIn(scope) .launchIn(scope)
basePreferences.hardwareBitmapThreshold.let { preference ->
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
}
basePreferences.hardwareBitmapThreshold.changes()
.onEach { ImageUtil.hardwareBitmapThreshold = it }
.launchIn(scope)
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get()) setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
// Updates widget update // Updates widget update
@@ -4,12 +4,8 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupManga import kotlinx.serialization.SerializationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import okio.BufferedSource
import okio.buffer import okio.buffer
import okio.gzip import okio.gzip
import okio.source import okio.source
@@ -23,48 +19,9 @@ class BackupDecoder(
private val parser: ProtoBuf, private val parser: ProtoBuf,
) { ) {
/** /**
* Decode everything but the entries of a potentially-gzipped backup, counting the entries without decoding them. * Decode a potentially-gzipped backup.
*/ */
fun decodeMetadata(uri: Uri): Metadata { fun decode(uri: Uri): Backup {
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 -> return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer() val source = inputStream.source().buffer()
@@ -72,32 +29,22 @@ class BackupDecoder(
require(2) require(2)
} }
val id1id2 = peeked.readShort() val id1id2 = peeked.readShort()
val backupSource: BufferedSource = when (id1id2.toInt()) { val backupString = when (id1id2.toInt()) {
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes 0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> { MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json)) throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
} }
else -> source 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 { companion object {
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}` private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"` private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
@@ -2,10 +2,7 @@ package eu.kanade.tachiyomi.data.backup
import android.net.Uri import android.net.Uri
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@Inject @Inject
@@ -21,11 +18,8 @@ class BackupFileValidator(
* @return List of missing sources or missing trackers. * @return List of missing sources or missing trackers.
*/ */
suspend fun validate(uri: Uri): Results { suspend fun validate(uri: Uri): Results {
val trackerIds = mutableSetOf<Int>()
val backup = try { val backup = try {
backupDecoder.decodeMetadata(uri, TrackedManga.serializer()) { manga -> backupDecoder.decode(uri)
manga.tracking.mapTo(trackerIds) { it.syncId }
}.backup
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException(e) throw IllegalStateException(e)
} }
@@ -44,7 +38,11 @@ class BackupFileValidator(
.distinct() .distinct()
.sorted() .sorted()
val missingTrackers = trackerIds val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = trackers
.mapNotNull { trackerManager.get(it.toLong()) } .mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn } .filter { !it.isLoggedIn }
.map { it.name } .map { it.name }
@@ -57,10 +55,4 @@ class BackupFileValidator(
val missingSources: List<String>, val missingSources: List<String>,
val missingTrackers: 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(),
)
} }
@@ -1,158 +0,0 @@
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,7 +8,6 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator 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.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
@@ -21,8 +20,6 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority import logcat.LogPriority
import okio.buffer import okio.buffer
@@ -88,31 +85,27 @@ class BackupCreator(
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList() val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options) 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() file.openOutputStream()
.also { .also {
// Force overwrite old file // Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0) (it as? FileOutputStream)?.channel?.truncate(0)
} }
.sink().gzip().buffer().use { .sink().gzip().buffer().use {
val writer = BackupWriter(it, parser) it.write(byteArray)
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 val fileUri = file.uri
@@ -137,14 +130,14 @@ class BackupCreator(
return categoriesBackupCreator() return categoriesBackupCreator()
} }
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> { private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
if (!options.libraryEntries) return emptyFlow() if (!options.libraryEntries) return emptyList()
return mangaBackupCreator(mangas, options) return mangaBackupCreator(mangas, options)
} }
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> { private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
return sourcesBackupCreator(sourceIds) return sourcesBackupCreator(mangas)
} }
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> { private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
@@ -7,13 +7,11 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode 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.toByteArray
import mihon.core.common.extensions.toEpochMillisOrZero import mihon.core.common.extensions.toEpochMillisOrZero
import tachiyomi.domain.category.repository.CategoryRepository import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.track.repository.TrackRepository import tachiyomi.domain.track.repository.TrackRepository
@@ -22,71 +20,62 @@ import tachiyomi.domain.track.repository.TrackRepository
class MangaBackupCreator( class MangaBackupCreator(
private val mangaRepository: MangaRepository, private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository, private val chapterRepository: ChapterRepository,
private val categoryRepository: CategoryRepository,
private val historyRepository: HistoryRepository,
private val trackRepository: TrackRepository, private val trackRepository: TrackRepository,
private val getCategories: GetCategories,
private val getHistory: GetHistory,
) { ) {
/** suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once. return mangas.map {
*/ backupManga(it, options)
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 backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> { private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
val mangaIds = mangas.map { it.id } // Entry for this manga
val mangaObject = manga.toBackupManga()
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds) mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
// History is backed up by chapter URL, so it needs the chapters too
val chaptersByMangaId = if (options.chapters || options.history) { if (options.chapters) {
chapterRepository.getChaptersByMangaIds(mangaIds) // Backup all the chapters
} else { chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
emptyMap() .map { it.toBackupChapter() }
.takeUnless { it.isEmpty() }
?.let { mangaObject.chapters = it }
} }
val categoriesByMangaId = if (options.categories) {
categoryRepository.getCategoriesByMangaIds(mangaIds) if (options.categories) {
} else { // Backup categories for this manga
emptyMap() val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
} }
val tracksByMangaId = if (options.tracking) trackRepository.getTracksByMangaIds(mangaIds) else emptyMap()
val historyByMangaId = if (options.history) historyRepository.getHistoryByMangaIds(mangaIds) else emptyMap()
return mangas.map { manga -> if (options.tracking) {
val mangaObject = manga.toBackupManga() val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
val chapters = chaptersByMangaId[manga.id].orEmpty() if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
if (options.chapters) {
mangaObject.chapters = chapters.map { it.toBackupChapter() }
} }
}
if (options.categories) { if (options.history) {
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order } val historyByMangaId = getHistory.await(manga.id)
} if (historyByMangaId.isNotEmpty()) {
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
if (options.tracking) { val history = historyByMangaId.mapNotNull { history ->
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 // A chapter removed since its history was read takes that history with it
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration) BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
} }
if (history.isNotEmpty()) {
mangaObject.history = history
}
} }
mangaObject
} }
}
private companion object { return mangaObject
const val CHUNK_SIZE = 100
} }
} }
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.backup.create.creators package eu.kanade.tachiyomi.data.backup.create.creators
import dev.zacsweers.metro.Inject 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.data.backup.models.BackupSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@@ -10,8 +11,11 @@ class SourcesBackupCreator(
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
) { ) {
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> { suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
return sourceIds.map { sourceManager.getOrStub(it).toBackupSource() } return mangas
.map(BackupManga::source)
.distinct()
.map { sourceManager.getOrStub(it).toBackupSource() }
} }
} }
@@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore 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.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
@@ -22,7 +23,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
@@ -97,7 +97,7 @@ class BackupRestorer(
} }
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) { private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
val (entryCount, backup) = backupDecoder.decodeMetadata(uri) val backup = backupDecoder.decode(uri)
// Store source mapping for error messages // Store source mapping for error messages
val backupMaps = backup.backupSources val backupMaps = backup.backupSources
@@ -105,7 +105,7 @@ class BackupRestorer(
if (options.libraryEntries) { if (options.libraryEntries) {
restoreSourceNames(backupMaps) restoreSourceNames(backupMaps)
restoreAmount += entryCount restoreAmount += backup.backupManga.size
} }
if (options.categories) { if (options.categories) {
restoreAmount += 1 restoreAmount += 1
@@ -138,7 +138,7 @@ class BackupRestorer(
} }
if (options.libraryEntries) { if (options.libraryEntries) {
restoreManga( restoreManga(
uri, backup.backupManga,
if (options.categories) backup.backupCategories else emptyList(), if (options.categories) backup.backupCategories else emptyList(),
restoreCategoriesJob, restoreCategoriesJob,
) )
@@ -175,15 +175,14 @@ class BackupRestorer(
} }
private fun CoroutineScope.restoreManga( private fun CoroutineScope.restoreManga(
uri: Uri, backupMangas: List<BackupManga>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
categoriesRestoreJob: Job?, categoriesRestoreJob: Job?,
) = launch { ) = launch {
categoriesRestoreJob?.join() categoriesRestoreJob?.join()
mangaRestorer.sortByNew(backupMangas)
backupDecoder.decodeEntries(uri)
.chunked(100) .chunked(100)
.collect { chunk -> .forEach { chunk ->
val restoredAsBatch = try { val restoredAsBatch = try {
ensureActive() ensureActive()
mangaRestorer.restore(chunk, backupCategories) mangaRestorer.restore(chunk, backupCategories)
@@ -23,6 +23,13 @@ class MangaRestorer(
private val now = Clock.System.now().toLocalDateTime(timeZone) private val now = Clock.System.now().toLocalDateTime(timeZone)
private val currentFetchWindow = fetchInterval.getWindow(now.date, 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. * Restores [backupMangas] all together, so either every one of them is restored or none is.
*/ */
@@ -0,0 +1,221 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import android.text.format.Formatter
import com.jakewharton.disklrucache.DiskLruCache
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response
import okio.buffer
import okio.sink
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import java.io.File
import java.io.IOException
/**
* Class used to create chapter cache
* For each image in a chapter a file is created
* For each chapter a Json list is created and converted to a file.
* The files are in format *md5key*.0
*
* @param context the application context.
*/
@Inject
@SingleIn(AppScope::class)
class ChapterCache(
private val context: Context,
private val json: Json,
) {
/** Cache class used for cache management. */
private val diskCache by lazy {
DiskLruCache.open(
File(context.cacheDir, "chapter_disk_cache"),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE,
)
}
/**
* Returns directory of cache.
*/
private val cacheDir: File
get() = diskCache.directory
/**
* Returns real size of directory.
*/
private val realSize: Long
get() = DiskUtil.getDirectorySize(cacheDir)
/**
* Returns real size of directory in human readable format.
*/
val readableSize: String
get() = Formatter.formatFileSize(context, realSize)
/**
* Get page list from cache.
*
* @param chapter the chapter.
* @return the list of pages.
*/
fun getPageListFromCache(chapter: Chapter): List<Page> {
// Get the key for the chapter.
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
// Convert JSON string to list of objects. Throws an exception if snapshot is null
return diskCache.get(key).use {
json.decodeFromString(it.getString(0))
}
}
/**
* Add page list to disk cache.
*
* @param chapter the chapter.
* @param pages list of pages.
*/
fun putPageListToCache(chapter: Chapter, pages: List<Page>) {
// Convert list of pages to json string.
val cachedValue = json.encodeToString(pages)
// Initialize the editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
try {
// Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
editor = diskCache.edit(key) ?: return
// Write chapter urls to cache.
editor.newOutputStream(0).sink().buffer().use {
it.write(cachedValue.toByteArray())
it.flush()
}
diskCache.flush()
editor.commit()
editor.abortUnlessCommitted()
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
// Ignore.
} finally {
editor?.abortUnlessCommitted()
}
}
/**
* Returns true if image is in cache.
*
* @param imageUrl url of image.
* @return true if in cache otherwise false.
*/
fun isImageInCache(imageUrl: String): Boolean {
return try {
val key = DiskUtil.hashKeyForDisk(imageUrl)
val inJournal = diskCache.get(key).use { it != null }
val fileExists = getImageFile(imageUrl).exists()
if (inJournal && !fileExists) {
logcat(LogPriority.WARN) { "Image is in journal but file is missing: $imageUrl" }
}
inJournal && fileExists
} catch (_: IOException) {
false
}
}
/**
* Get image file from url.
*
* @param imageUrl url of image.
* @return path of image.
*/
fun getImageFile(imageUrl: String): File {
// Get file from md5 key.
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName)
}
/**
* Add image to cache.
*
* @param imageUrl url of image.
* @param response http response from page.
* @throws IOException image error.
*/
@Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response) {
// Initialize editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
try {
// Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(imageUrl)
editor = diskCache.edit(key) ?: return
// Get OutputStream and write image with Okio.
response.body.source().saveTo(editor.newOutputStream(0))
diskCache.flush()
editor.commit()
} finally {
response.body.close()
editor?.abortUnlessCommitted()
}
}
fun clear(): Int {
var deletedFiles = 0
cacheDir.listFiles()?.forEach {
if (removeFileFromCache(it.name)) {
deletedFiles++
}
}
return deletedFiles
}
/**
* Remove file from cache.
*
* @param file name of file "md5.0".
* @return status of deletion for the file.
*/
private fun removeFileFromCache(file: String): Boolean {
// Make sure we don't delete the journal file (keeps track of cache)
if (file == "journal" || file.startsWith("journal.")) {
return false
}
return try {
// Remove the extension from the file to get the key of the cache
val key = file.substringBeforeLast(".")
// Remove file from cache
diskCache.remove(key)
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
false
}
}
private fun getKey(chapter: Chapter): String {
return "${chapter.mangaId}${chapter.url}"
}
}
/** Application cache version. */
private const val PARAMETER_APP_VERSION = 1
/** The number of values per cache entry. Must be positive. */
private const val PARAMETER_VALUE_COUNT = 1
/** The maximum number of bytes this cache should use to store. */
private const val PARAMETER_CACHE_SIZE = 100L * 1024 * 1024
@@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix import android.graphics.Matrix
import androidx.core.graphics.createBitmap
import ca.mpreg.imagedecoder.ImageDecoder
import coil3.Canvas import coil3.Canvas
import coil3.Image import coil3.Image
import coil3.ImageLoader import coil3.ImageLoader
@@ -14,39 +12,20 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import coil3.request.bitmapConfig
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder
import kotlin.math.min import kotlin.math.min
import kotlin.math.sqrt import kotlin.math.sqrt
/** /**
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported * A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
*/ */
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder { class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
/**
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
*/
class DecodeResultImage(
val frame: ImageDecoder.Frame,
val isHdr: Boolean,
val hdrHeadroom: Float,
val gainmap: ImageDecoder.Gainmap?,
) : Image {
val image: java.nio.ByteBuffer get() = frame.image
// Taken now: a caller may close the frame once it has the pixels.
override val size: Long = frame.image.capacity().toLong()
override val width: Int get() = frame.width
override val height: Int get() = frame.height
override val shareable: Boolean get() = true
override fun draw(canvas: Canvas) {}
}
/** /**
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an * A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of * immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
@@ -63,105 +42,102 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override suspend fun decode(): DecodeResult { override suspend fun decode(): DecodeResult {
if (options.webtoonStrips) return decodeStrips() if (options.webtoonStrips) return decodeStrips()
return resources.source().use { val decoder = resources.sourceOrNull()?.use {
ImageDecoder.open(it.inputStream()).use { dec -> ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
// 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. check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = dec.width, val srcWidth = decoder.width
srcHeight = dec.height, val srcHeight = decoder.height
dstWidth = options.size.widthPx(options.scale) { dec.width },
dstHeight = options.size.heightPx(options.scale) { dec.height }, val dstWidth = options.size.widthPx(options.scale) { srcWidth }
scale = options.scale, val dstHeight = options.size.heightPx(options.scale) { srcHeight }
)
DecodeResult( val sampleSize = DecodeUtils.calculateInSampleSize(
image = dec.decodeToBitmap(null, sampleSize).asImage(), srcWidth = srcWidth,
isSampled = sampleSize > 1, srcHeight = srcHeight,
) dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
var bitmap = decoder.decode(sampleSize = sampleSize)
decoder.recycle()
check(bitmap != null) { "Failed to decode image" }
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
bitmap.recycle()
bitmap = hwBitmap
} }
} }
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,
)
} }
/** /**
* Copies bands of rows straight from the native frame into strips, so the page never exists as * Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied.
* 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 * 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 * 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. * [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
*/ */
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit { private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
val (frame, isHdr, widths) = resources.source().use { val decoder = resources.sourceOrNull()?.use {
ImageDecoder.open(it.inputStream()).use { dec -> ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
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 check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
frame.use {
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
val bytesPerPixel = if (isHdr) 8 else 4
val rowBytes = frame.width * bytesPerPixel
val scale = stripWidth.toFloat() / frame.width
// Strips of about the same size in bytes, so each takes about as long to upload val srcWidth = decoder.width
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1) val viewWidth = options.size.widthPx(options.scale) { srcWidth }
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height) val sampleSize = DecodeUtils.calculateInSampleSize(
val matrix = Matrix().apply { setScale(scale, scale) } srcWidth = decoder.width,
val band = createBitmap(frame.width, bandRows, config) srcHeight = decoder.height,
val strips = (0..<frame.height step bandRows).map { top -> dstWidth = viewWidth,
val rows = min(bandRows, frame.height - top) dstHeight = options.size.heightPx(options.scale) { decoder.height },
if (rows != band.height) band.reconfigure(frame.width, rows, config) scale = options.scale,
band.copyPixelsFromBuffer( )
frame.image.duplicate().apply { val page = decoder.decode(sampleSize = sampleSize)
position(top * rowBytes) decoder.recycle()
limit((top + rows) * rowBytes) check(page != null) { "Failed to decode image" }
},
) val config = page.config ?: Bitmap.Config.ARGB_8888
val strip = if (stripWidth < frame.width) { val bytesPerPixel = page.rowBytes / page.width
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true) val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt()
} else { val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth))
band // Older GPUs can't draw a texture wider than this
} .coerceAtMost(MAX_STRIP_WIDTH)
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one .coerceIn(1, page.width)
strip.copy(config, false).also { if (strip !== band) strip.recycle() } val scale = stripWidth.toFloat() / page.width
// Strips of about the same size in bytes, so each takes about as long to upload
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height)
val matrix = Matrix().apply { setScale(scale, scale) }
val strips = (0..<page.height step bandRows).map { top ->
val rows = min(bandRows, page.height - top)
val band = if (stripWidth < page.width) {
Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true)
} else {
Bitmap.createBitmap(page, 0, top, page.width, rows)
} }
band.recycle() // createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth) band.copy(config, false).also { if (band !== page) band.recycle() }
} }
page.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth)
} }
class Factory : Decoder.Factory { class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? { override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) { return if (options.webtoonStrips || isApplicable(result.source.source())) {
ImageDecoder(result.source, options) ImageDecoder(result.source, options)
} else { } else {
null null
@@ -173,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
ImageUtil.findImageType(it) ImageUtil.findImageType(it)
} }
return when (type) { return when (type) {
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
ImageUtil.ImageType.JXL,
ImageUtil.ImageType.HEIF,
ImageUtil.ImageType.JP2,
-> true
else -> false else -> false
} }
} }
@@ -187,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
override fun hashCode() = javaClass.hashCode() override fun hashCode() = javaClass.hashCode()
} }
companion object {
var displayProfile: ByteArray? = null
}
} }
private const val STRIP_BYTES = 4 * 1024 * 1024 private const val STRIP_BYTES = 4 * 1024 * 1024
@@ -195,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
private const val MAX_STRIP_WIDTH = 4096 private const val MAX_STRIP_WIDTH = 4096
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel // Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel
private val stripDecodes = Semaphore(1) private val stripDecodes = Semaphore(1)
@@ -60,9 +60,9 @@ class MangaCoverFetcher(
get() = diskCacheKeyLazy.value get() = diskCacheKeyLazy.value
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
// Use custom cover if exists, which only library manga can have // Use custom cover if exists
val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY) val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY)
if (useCustomCover && isLibraryManga) { if (useCustomCover) {
val customCoverFile = customCoverFileLazy.value val customCoverFile = customCoverFileLazy.value
if (customCoverFile.exists()) { if (customCoverFile.exists()) {
return fileLoader(customCoverFile) return fileLoader(customCoverFile)
@@ -9,7 +9,7 @@ import tachiyomi.domain.manga.model.Manga as DomainManga
class MangaKeyer : Keyer<DomainManga> { class MangaKeyer : Keyer<DomainManga> {
override fun key(data: DomainManga, options: Options): String { override fun key(data: DomainManga, options: Options): String {
return if (data.favorite && data.hasCustomCover()) { return if (data.hasCustomCover()) {
"${data.id};${data.coverLastModified}" "${data.id};${data.coverLastModified}"
} else { } else {
"${data.thumbnailUrl};${data.coverLastModified}" "${data.thumbnailUrl};${data.coverLastModified}"
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
private val coverCache: CoverCache, private val coverCache: CoverCache,
) : Keyer<MangaCover> { ) : Keyer<MangaCover> {
override fun key(data: MangaCover, options: Options): String { override fun key(data: MangaCover, options: Options): String {
return if (data.isMangaFavorite && coverCache.hasCustomCover(data.mangaId)) { return if (coverCache.hasCustomCover(data.mangaId)) {
"${data.mangaId};${data.lastModified}" "${data.mangaId};${data.lastModified}"
} else { } else {
"${data.url};${data.lastModified}" "${data.url};${data.lastModified}"
@@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean
get() = getExtra(webtoonStripsKey) get() = getExtra(webtoonStripsKey)
private val webtoonStripsKey = Extras.Key(default = false) private val webtoonStripsKey = Extras.Key(default = false)
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
extras[newDecoderKey] = enable
}
val Options.newDecoder: Boolean
get() = getExtra(newDecoderKey)
private val newDecoderKey = Extras.Key(default = false)
@@ -186,8 +186,7 @@ class DownloadCache(
fun getTotalDownloadCount(): Int { fun getTotalDownloadCount(): Int {
renewCache() renewCache()
// The disk index stores a folder shared by several ids once per id return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
return rootDownloadsDir.sourceDirs.values.distinctBy { it.dir?.uri }.sumOf { sourceDir ->
sourceDir.mangaDirs.values.sumOf { mangaDir -> sourceDir.mangaDirs.values.sumOf { mangaDir ->
mangaDir.chapterDirs.size mangaDir.chapterDirs.size
} }
@@ -391,21 +390,20 @@ class DownloadCache(
sources = getSources() sources = getSources()
} }
// A stored stub can share its name with an installed source, so a folder can belong to several ids val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
val sourceIdsByDirName = sources.groupBy({ provider.getSourceDirName(it).lowercase() }, { it.id })
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory()) val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory())
updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty() updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
.flatMap { dir -> .mapNotNull { dir ->
val sourceDir = SourceDirectory(dir) val sourceId = sourceMap[dir.name!!.lowercase()]
sourceIdsByDirName[dir.name!!.lowercase()].orEmpty().map { it to sourceDir } sourceId?.let { it to SourceDirectory(dir) }
} }
.toMap() .toMap()
updatedRootDir.sourceDirs.values.distinct().map { sourceDir -> updatedRootDir.sourceDirs.values.map { sourceDir ->
async { async {
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty() sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() } .filter { it.isDirectory && !it.name.isNullOrBlank() }
@@ -231,7 +231,7 @@ class DownloadProvider(
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(), enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
): String { ): String {
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl) return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash) .dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
} }
/** /**
@@ -254,18 +254,14 @@ class DownloadProvider(
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true) val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
val othersChapterDirNames = booleanPairPermutation val othersChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) -> .map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash) parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
}
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. // Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
val scanlator = parts.chapterScanlator val scanlator = parts.chapterScanlator
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) { val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) -> booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false) parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
} }
} else { } else {
emptyList() emptyList()
@@ -276,7 +272,6 @@ class DownloadProvider(
// chapter names) // chapter names)
add(chapterNameV1) add(chapterNameV1)
addAll(othersChapterDirNames) addAll(othersChapterDirNames)
addAll(splitChapterDirNames)
addAll(unsanitizedScanlatorChapterDirNames) addAll(unsanitizedScanlatorChapterDirNames)
} }
} }
@@ -296,6 +291,7 @@ class DownloadProvider(
val chapterDirName = parts.dirName( val chapterDirName = parts.dirName(
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(), disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
enableHash = libraryPreferences.enableChapterNameHash.get(), enableHash = libraryPreferences.enableChapterNameHash.get(),
sanitizeScanlator = true,
) )
val legacyChapterDirNames = getLegacyChapterDirNames(parts) val legacyChapterDirNames = getLegacyChapterDirNames(parts)
@@ -334,29 +330,9 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
chapterScanlator?.let { DiskUtil.buildValidFilename(it) } chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
} }
private val validDirNames = arrayOfNulls<String>(2)
private val validChapterNames = arrayOfNulls<String>(2) private val validChapterNames = arrayOfNulls<String>(2)
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean): String { fun dirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: 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 { return buildString {
if (!chapterScanlator.isNullOrBlank()) { if (!chapterScanlator.isNullOrBlank()) {
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own // A "/" in the scanlator would otherwise nest the chapter in a folder of its own
@@ -7,6 +7,7 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.chapter.model.toSChapter import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.domain.manga.model.getComicInfo 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.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationHandler
@@ -49,7 +50,6 @@ import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.archive.ZipWriter import mihon.core.archive.ZipWriter
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import mihon.data.cache.ChapterCache
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
@@ -64,6 +64,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.io.File
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -543,11 +544,15 @@ class Downloader(
// If the image is already downloaded, do nothing. Otherwise download from network // If the image is already downloaded, do nothing. Otherwise download from network
val file = when { val file = when {
imageFile != null -> imageFile imageFile != null -> imageFile
else -> copyImageFromCache(page.imageUrl!!, tmpDir, filename) chapterCache.isImageInCache(
?: downloadImage(page, download.source, tmpDir, filename) page.imageUrl!!,
) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename)
else -> downloadImage(page, download.source, tmpDir, filename)
} }
// When the page is ready, set page path, progress (just in case) and status // When the page is ready, set page path, progress (just in case) and status
splitTallImageIfNeeded(page, tmpDir)
page.uri = file.uri page.uri = file.uri
page.progress = 100 page.progress = 100
page.status = Page.State.Ready page.status = Page.State.Ready
@@ -607,25 +612,24 @@ class Downloader(
} }
/** /**
* Copies the image from cache to file in tmpDir, or returns null when it isn't cached. * Copies the image from cache to file in tmpDir.
* *
* @param imageUrl the url of the image. * @param cacheFile the file from cache.
* @param tmpDir the temporary directory of the download. * @param tmpDir the temporary directory of the download.
* @param filename the filename of the image. * @param filename the filename of the image.
*/ */
private suspend fun copyImageFromCache(imageUrl: String, tmpDir: UniFile, filename: String): UniFile? { private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
val tmpFile = chapterCache.openImage(imageUrl)?.use { input -> // Delete temp file if it exists
// Delete temp file if it exists tmpDir.findFile("$filename.tmp")?.delete()
tmpDir.findFile("$filename.tmp")?.delete() val tmpFile = tmpDir.createFile("$filename.tmp")!!
tmpDir.createFile("$filename.tmp")!!.also { tmpFile -> cacheFile.inputStream().use { input ->
tmpFile.openOutputStream().use { output -> tmpFile.openOutputStream().use { output ->
input.copyTo(output) input.copyTo(output)
}
} }
} ?: return null }
chapterCache.removeImage(imageUrl) val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile
val extension = tmpFile.openInputStream().use { ImageUtil.findImageType(it) } ?: return tmpFile
tmpFile.renameTo("$filename.${extension.extension}") tmpFile.renameTo("$filename.${extension.extension}")
cacheFile.delete()
return tmpFile return tmpFile
} }
@@ -641,6 +645,23 @@ class Downloader(
return ImageUtil.getExtensionFromMimeType(mime) { file.openInputStream() } 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. * Checks if the download was successful.
* *
@@ -29,6 +29,9 @@ data class Download(
_pagesFlow.value = pages _pagesFlow.value = pages
} }
val totalProgress: Int
get() = pages?.sumOf(Page::progress) ?: 0
val downloadedImages: Int val downloadedImages: Int
get() = pages?.count { it.status == Page.State.Ready } ?: 0 get() = pages?.count { it.status == Page.State.Ready } ?: 0
@@ -260,7 +260,6 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
coroutineScope { coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values mangaToUpdate.groupBy { it.manga.source }.values
.sortedByDescending { it.size }
.map { mangaInSource -> .map { mangaInSource ->
async { async {
semaphore.withPermit { semaphore.withPermit {
@@ -50,6 +50,20 @@ object NotificationHandler {
) )
} }
/**
* Returns [PendingIntent] that prompts user with apk install intent
*
* @param context context
* @param uri uri of apk that is installed
*/
fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, ExtensionInstaller.APK_MIME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
fun openUrl(context: Context, url: String): PendingIntent { fun openUrl(context: Context, url: String): PendingIntent {
val notificationIntent = Intent(Intent.ACTION_VIEW, url.toUri()) val notificationIntent = Intent(Intent.ACTION_VIEW, url.toUri())
return PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) return PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
@@ -294,6 +294,26 @@ class NotificationReceiver : BroadcastReceiver() {
) )
} }
/**
* Returns [PendingIntent] that starts a service which dismissed the notification
*
* @param context context of application
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_DISMISS_NOTIFICATION
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** /**
* Returns [PendingIntent] that starts a service which dismissed the notification * Returns [PendingIntent] that starts a service which dismissed the notification
* *
@@ -33,7 +33,6 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Base64 import java.util.Base64
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -78,14 +77,7 @@ class ComickApi(
put("status", track.status.toApiListStatus()) put("status", track.status.toApiListStatus())
if (track.last_chapter_read > 0) { if (track.last_chapter_read > 0) {
putJsonObject("progress") { putJsonObject("progress") {
put( put("number", track.last_chapter_read)
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
} }
} else { } else {
put("progress", null) put("progress", null)
@@ -148,14 +140,7 @@ class ComickApi(
put("rating", track.score.toInt().takeIf { it > 0 }) put("rating", track.score.toInt().takeIf { it > 0 })
put("status", track.status.toApiListStatus()) put("status", track.status.toApiListStatus())
putJsonObject("progress") { putJsonObject("progress") {
put( put("number", track.last_chapter_read.toString())
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
} }
} }
.toString() .toString()
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -58,16 +57,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
return return
} }
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { service.startActivity(userAction)
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 -> { PackageInstaller.STATUS_FAILURE_ABORTED -> {
continueQueue(InstallStep.Idle) continueQueue(InstallStep.Idle)
@@ -24,7 +24,6 @@ import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
import java.io.IOException
/** /**
* Class that handles the loading of the extensions. Supports two kinds of extensions: * Class that handles the loading of the extensions. Supports two kinds of extensions:
@@ -81,12 +80,12 @@ internal object ExtensionLoader {
} }
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
val staged = File(target.parentFile, "${target.name}.part")
try { try {
file.copyAndSetReadOnlyTo(staged, overwrite = true) target.delete()
if (!staged.renameTo(target)) throw IOException("Unable to replace ${target.name}") file.copyAndSetReadOnlyTo(target, overwrite = true)
} finally { } catch (e: Exception) {
staged.delete() target.delete()
throw e
} }
if (currentExtension != null) { if (currentExtension != null) {
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName) ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
@@ -1,73 +1,49 @@
package eu.kanade.tachiyomi.ui.browse.extension.details package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.os.Bundle
import android.view.ViewGroup import android.util.TypedValue
import android.view.WindowManager import android.view.View
import android.widget.Button import androidx.appcompat.view.ContextThemeWrapper
import android.widget.EditText import androidx.compose.foundation.layout.fillMaxSize
import android.widget.LinearLayout import androidx.compose.foundation.layout.padding
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.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.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.DialogWindowProvider import androidx.fragment.app.FragmentActivity
import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.FragmentContainerView
import androidx.preference.CheckBoxPreference import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.preference.DialogPreference import androidx.preference.DialogPreference
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.PreferenceFragmentCompat
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference import androidx.preference.R
import androidx.preference.forEach
import androidx.preference.getOnBindEditTextListener import androidx.preference.getOnBindEditTextListener
import androidx.preference.setOnStateChangeListener
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar 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.presentation.util.Screen
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.util.view.setIncognito import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.app.di.appGraph 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.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
class SourcePreferencesScreen(val sourceId: Long) : Screen() { class SourcePreferencesScreen(val sourceId: Long) : Screen() {
@@ -77,290 +53,135 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
// The preferences are made anew after a configuration change, so an open dialog is restored by key val source by produceState<Source?>(initialValue = null) {
var savedDialogKey by rememberSaveable { mutableStateOf<String?>(null) } value = context.appGraph.sourceManager.getOrStub(sourceId)
val screen by produceState<ExtensionPreferenceScreen?>(initialValue = null) {
val source = context.appGraph.sourceManager.getOrStub(sourceId)
// Reading a value loads the file, which shouldn't happen on the main thread
val sourcePreferences = (source as? ConfigurableSource)?.let {
withContext(Dispatchers.IO) { it.sourcePreferences().apply { all } }
}
value = ExtensionPreferenceScreen(context, source, sourcePreferences, savedDialogKey)
} }
val preferenceScreen = screen if (source == null) {
if (preferenceScreen == null) {
LoadingScreen() LoadingScreen()
return return
} }
DisposableEffect(preferenceScreen) {
onDispose { preferenceScreen.dispose() }
}
SideEffect { savedDialogKey = preferenceScreen.dialogPreference?.key }
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( AppBar(
title = preferenceScreen.title, title = source.toString(),
navigateUp = navigator::pop, navigateUp = navigator::pop,
scrollBehavior = it, scrollBehavior = it,
) )
}, },
) { contentPadding -> ) { contentPadding ->
val items = remember(preferenceScreen.version) { preferenceScreen.items() } FragmentContainer(
ScrollbarLazyColumn(contentPadding = contentPadding) { fragmentManager = (context as FragmentActivity).supportFragmentManager,
items(items) { PreferenceRow(it) } modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
) {
add(it, SourcePreferencesFragment.getInstance(sourceId), null)
} }
} }
}
preferenceScreen.dialogPreference?.let { /**
PreferenceDialog( * From https://stackoverflow.com/questions/60520145/fragment-container-in-jetpack-compose/70817794#70817794
preference = it, */
onDismissRequest = { preferenceScreen.dialogPreference = null }, @Composable
) private fun FragmentContainer(
fragmentManager: FragmentManager,
modifier: Modifier = Modifier,
commit: FragmentTransaction.(containerId: Int) -> Unit,
) {
val containerId by rememberSaveable {
mutableIntStateOf(View.generateViewId())
} }
} var initialized by rememberSaveable { mutableStateOf(false) }
} AndroidView(
modifier = modifier,
/** factory = { context ->
* The screen an extension fills in [ConfigurableSource.setupPreferenceScreen]. The [androidx.preference] objects stay FragmentContainerView(context)
* the model, so extensions keep the exact behaviour of the library, and are only drawn with Compose. .apply { id = containerId }
*/
@Stable
@SuppressLint("RestrictedApi")
private class ExtensionPreferenceScreen(
context: Context,
source: Source,
sourcePreferences: SharedPreferences?,
dialogKey: String?,
) {
val title = source.toString()
// Bumped on every change the library would rebind a preference's view for
var version by mutableIntStateOf(0)
private set
var dialogPreference by mutableStateOf<DialogPreference?>(null)
private val screen: PreferenceScreen
init {
val preferenceManager = PreferenceManager(context)
if (sourcePreferences != null) {
preferenceManager.preferenceDataStore = SharedPreferencesDataStore(sourcePreferences)
}
preferenceManager.setOnDisplayPreferenceDialogListener { dialogPreference = it as? DialogPreference }
screen = preferenceManager.createPreferenceScreen(context)
(source as? ConfigurableSource)?.setupPreferenceScreen(screen)
preferenceManager.setPreferences(screen)
screen.onAttached()
observe(screen)
dialogPreference = dialogKey?.let { screen.findPreference<Preference>(it) as? DialogPreference }
}
private fun observe(preference: Preference) {
preference.setOnStateChangeListener {
// A group changing may mean preferences were added to it
if (it is PreferenceGroup) observe(it)
version++
}
if (preference is PreferenceGroup) {
repeat(preference.preferenceCount) { observe(preference.getPreference(it)) }
}
}
fun items(): List<PreferenceItem> = buildList { addVisible(screen) }
private fun MutableList<PreferenceItem>.addVisible(group: PreferenceGroup) {
repeat(group.preferenceCount) {
val preference = group.getPreference(it)
if (!preference.isVisible) return@repeat
add(PreferenceItem.of(preference))
if (preference is PreferenceGroup) addVisible(preference)
}
}
fun dispose() {
screen.onDetached()
}
}
/**
* What a preference shows, read once per change so the list redraws when an extension changes a preference.
*/
private data class PreferenceItem(
val preference: Preference,
val title: String?,
val summary: String?,
val enabled: Boolean,
val checked: Boolean?,
) {
companion object {
fun of(preference: Preference): PreferenceItem {
val summary = when {
preference !is TwoStatePreference -> preference.summary
preference.isChecked && !preference.summaryOn.isNullOrEmpty() -> preference.summaryOn
!preference.isChecked && !preference.summaryOff.isNullOrEmpty() -> preference.summaryOff
else -> preference.summary
}
return PreferenceItem(
preference = preference,
title = preference.title?.toString(),
summary = summary?.toString(),
enabled = preference.isEnabled,
checked = (preference as? TwoStatePreference)?.isChecked,
)
}
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun PreferenceRow(item: PreferenceItem) {
val preference = item.preference
val onClick = if (preference.isSelectable) preference::performClick else null
when {
preference is PreferenceGroup -> PreferenceGroupHeader(title = item.title.orEmpty())
preference is CheckBoxPreference -> CheckboxPreferenceWidget(
title = item.title.orEmpty(),
subtitle = item.summary,
checked = item.checked == true,
enabled = item.enabled,
onCheckedChanged = { onClick?.invoke() },
)
item.checked != null -> SwitchPreferenceWidget(
title = item.title.orEmpty(),
subtitle = item.summary,
checked = item.checked,
enabled = item.enabled,
onCheckedChanged = { onClick?.invoke() },
)
else -> TextPreferenceWidget(
title = item.title,
subtitle = item.summary,
enabled = item.enabled,
onPreferenceClick = onClick,
)
}
}
@Composable
private fun PreferenceDialog(preference: DialogPreference, onDismissRequest: () -> Unit) {
val title = (preference.dialogTitle?.takeIf { it.isNotEmpty() } ?: preference.title)?.toString()
val message = preference.dialogMessage?.toString()
when (preference) {
is ListPreference -> ListPreferenceDialog(
title = title,
message = message,
value = preference.value,
entries = entryMap(preference.entries, preference.entryValues),
onValueChange = {
if (preference.callChangeListener(it)) preference.value = it
}, },
onDismissRequest = onDismissRequest, update = { view ->
) if (!initialized) {
is MultiSelectListPreference -> MultiSelectListPreferenceDialog( fragmentManager.commit { commit(view.id) }
title = title, initialized = true
message = message, } else {
values = preference.values, fragmentManager.onContainerAvailable(view)
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() }
/** Access to package-private method in FragmentManager through reflection */
private fun FragmentManager.onContainerAvailable(view: FragmentContainerView) {
val method = FragmentManager::class.java.getDeclaredMethod(
"onContainerAvailable",
FragmentContainerView::class.java,
)
method.isAccessible = true
method.invoke(this, view)
} }
} }
private fun entryMap(entries: Array<CharSequence>?, entryValues: Array<CharSequence>?): Map<String, String> { class SourcePreferencesFragment : PreferenceFragmentCompat() {
return entryValues.orEmpty().zip(entries.orEmpty()) { value, entry -> value.toString() to entry.toString() }.toMap()
}
/** override fun getContext(): Context? {
* Extensions get a real [EditText] through [EditTextPreference.OnBindEditTextListener], and some validate input by val superCtx = super.getContext() ?: return null
* disabling the dialog's `android.R.id.button1` found from [EditText.getRootView], so one stands in for the OK button. val tv = TypedValue()
*/ superCtx.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
@Composable return ContextThemeWrapper(superCtx, tv.resourceId)
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) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
super.setEnabled(enabled) preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
confirmEnabled = enabled lifecycleScope.launch {
} preferenceScreen = populateScreen()
} }
addView(confirmButton) }
val field = EditText(viewContext).apply {
// In the order the library's own dialog binds it private suspend fun populateScreen(): PreferenceScreen {
setText(draft ?: preference.text) val sourceId = requireArguments().getLong(SOURCE_ID)
setSelection(text.length) val appGraph = requireContext().appGraph
setIncognito(context.appGraph.basePreferences, scope) val source = appGraph.sourceManager.getOrStub(sourceId)
preference.getOnBindEditTextListener()?.onBindEditText(this) val basePreferences = appGraph.basePreferences
doAfterTextChanged { draft = it?.toString() } val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
// Focus only takes once attached to the dialog's window
post { requestFocus() } if (source is ConfigurableSource) {
} val sourcePreferences = withContext(Dispatchers.IO) { source.sourcePreferences().also { it.all } }
addView(field, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) val dataStore = SharedPreferencesDataStore(sourcePreferences)
editText = field preferenceManager.preferenceDataStore = dataStore
}
}, source.setupPreferenceScreen(sourceScreen)
modifier = Modifier.fillMaxWidth(), sourceScreen.forEach { pref ->
) pref.isIconSpaceReserved = false
} pref.isSingleLineTitle = false
}, if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
confirmButton = { pref.dialogTitle = pref.title
TextButton( }
enabled = confirmEnabled,
onClick = { // Apply incognito IME for EditTextPreference
val value = editText?.text?.toString() if (pref is EditTextPreference) {
if (value != null && preference.callChangeListener(value)) { val setListener = pref.getOnBindEditTextListener()
preference.text = value pref.setOnBindEditTextListener {
setListener?.onBindEditText(it)
it.setIncognito(basePreferences, lifecycleScope)
} }
onDismissRequest() }
},
) {
Text(text = stringResource(MR.strings.action_ok))
} }
}, }
dismissButton = {
TextButton(onClick = onDismissRequest) { return sourceScreen
Text(text = stringResource(MR.strings.action_cancel)) }
companion object {
private const val SOURCE_ID = "source_id"
fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply {
arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
} }
}, }
) }
} }
@@ -239,8 +239,11 @@ class BrowseSourceViewModel(
fun changeMangaFavorite(manga: Manga) { fun changeMangaFavorite(manga: Manga) {
appScope.launch { appScope.launch {
val update = if (manga.favorite) { val update = if (manga.favorite) {
manga.removeCovers(coverCache) val coverLastModified = manga.removeCovers(coverCache).coverLastModified
MangaUpdate(manga.id) { favoriteAt = null } MangaUpdate(manga.id) {
favoriteAt = null
if (coverLastModified != manga.coverLastModified) this.coverLastModified = coverLastModified
}
} else { } else {
setMangaDefaultChapterFlags.await(manga) setMangaDefaultChapterFlags.await(manga)
addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source)) 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_IGNORE -> TriState.DISABLED
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
else -> TriState.DISABLED else -> throw IllegalStateException("Unknown TriState state: $this")
} }
} }
@@ -0,0 +1,27 @@
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)
}
}
@@ -0,0 +1,34 @@
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)
}
}
@@ -0,0 +1,64 @@
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
}
}
@@ -0,0 +1,101 @@
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)
},
)
}
}
@@ -0,0 +1,68 @@
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,9 +1,11 @@
package eu.kanade.tachiyomi.ui.download package eu.kanade.tachiyomi.ui.download
import android.view.LayoutInflater
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -17,6 +19,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -24,11 +27,17 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metrox.viewmodel.metroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel
@@ -36,8 +45,10 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.components.NestedMenuItem import eu.kanade.presentation.components.NestedMenuItem
import eu.kanade.presentation.download.DownloadQueueList
import eu.kanade.presentation.util.Screen 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.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Sort import mihon.icons.materialsymbols.automirroredrounded.Sort
import mihon.icons.materialsymbols.roundedfilled.Pause import mihon.icons.materialsymbols.roundedfilled.Pause
@@ -47,16 +58,18 @@ import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
import kotlin.math.roundToInt
object DownloadQueueScreen : Screen() { object DownloadQueueScreen : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val viewModel = metroViewModel<DownloadQueueViewModel>() val viewModel = metroViewModel<DownloadQueueViewModel>()
val downloadList by viewModel.state.collectAsStateWithLifecycle() val downloadList by viewModel.state.collectAsStateWithLifecycle()
val downloadCount by remember { val downloadCount by remember {
derivedStateOf { downloadList.sumOf { it.downloads.size } } derivedStateOf { downloadList.sumOf { it.subItems.size } }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -122,7 +135,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_newest)) }, text = { Text(text = stringResource(MR.strings.action_newest)) },
onClick = { onClick = {
viewModel.reorderQueue( viewModel.reorderQueue(
{ it.chapter.dateUpload }, { it.download.chapter.dateUpload },
true, true,
) )
closeMenu() closeMenu()
@@ -132,7 +145,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_oldest)) }, text = { Text(text = stringResource(MR.strings.action_oldest)) },
onClick = { onClick = {
viewModel.reorderQueue( viewModel.reorderQueue(
{ it.chapter.dateUpload }, { it.download.chapter.dateUpload },
false, false,
) )
closeMenu() closeMenu()
@@ -147,7 +160,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_asc)) }, text = { Text(text = stringResource(MR.strings.action_asc)) },
onClick = { onClick = {
viewModel.reorderQueue( viewModel.reorderQueue(
{ it.chapter.chapterNumber }, { it.download.chapter.chapterNumber },
false, false,
) )
closeMenu() closeMenu()
@@ -157,7 +170,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_desc)) }, text = { Text(text = stringResource(MR.strings.action_desc)) },
onClick = { onClick = {
viewModel.reorderQueue( viewModel.reorderQueue(
{ it.chapter.chapterNumber }, { it.download.chapter.chapterNumber },
true, true,
) )
closeMenu() closeMenu()
@@ -227,17 +240,49 @@ object DownloadQueueScreen : Screen() {
return@Scaffold return@Scaffold
} }
DownloadQueueList( val density = LocalDensity.current
sections = downloadList, val layoutDirection = LocalLayoutDirection.current
lazyListState = rememberLazyListState(), val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
contentPadding = contentPadding, val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
onReorder = viewModel::reorder, val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
onMove = viewModel::move, val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
onMoveSeries = viewModel::moveSeries,
onCancel = { viewModel.cancel(listOf(it)) }, Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
onCancelSeries = viewModel::cancelSeries, AndroidView(
modifier = Modifier.nestedScroll(nestedScrollConnection), 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)
},
)
}
} }
} }
} }
@@ -1,22 +1,33 @@
package eu.kanade.tachiyomi.ui.download package eu.kanade.tachiyomi.ui.download
import androidx.compose.runtime.Immutable import android.view.MenuItem
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey import dev.zacsweers.metrox.viewmodel.ViewModelKey
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download 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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed 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.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@@ -26,18 +37,117 @@ class DownloadQueueViewModel(
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
) : ViewModel() { ) : ViewModel() {
val state: StateFlow<List<DownloadQueueSection>> = downloadManager.queueState val state: StateFlow<List<DownloadHeaderItem>> = downloadManager.queueState
.map { downloads -> .map { downloads ->
downloads downloads
.groupBy { it.source } .groupBy { it.source }
.map { (source, downloads) -> DownloadQueueSection(source.id, source.name, downloads) } .map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
addSubItems(0, entry.value.map { DownloadItem(it, this) })
}
}
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) .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 val isDownloaderRunning = downloadManager.isDownloaderRunning
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
fun getDownloadStatusFlow() = downloadManager.statusFlow()
fun getDownloadProgressFlow() = downloadManager.progressFlow()
fun startDownloads() { fun startDownloads() {
downloadManager.startDownloads() downloadManager.startDownloads()
} }
@@ -58,59 +168,102 @@ class DownloadQueueViewModel(
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) } viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
} }
/** fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
* Moves [download] to the top or bottom of its section. val adapter = adapter ?: return
*/ val newDownloads = mutableListOf<Download>()
fun move(download: Download, toTop: Boolean) { adapter.headerItems.forEach { headerItem ->
reorder( headerItem as DownloadHeaderItem
state.value.flatMap { section -> headerItem.subItems = headerItem.subItems.sortedBy(selector).toMutableList().apply {
if (download in section.downloads) { if (reverse) {
val others = section.downloads - download reverse()
if (toTop) listOf(download) + others else others + download
} else {
section.downloads
} }
}, }
) newDownloads.addAll(headerItem.subItems.map { it.download })
}
reorder(newDownloads)
} }
/** /**
* Moves all the downloads of the manga of [download] to the top or bottom of the queue. * Called when the status of a download changes.
*
* @param download the download whose status has changed.
*/ */
fun moveSeries(download: Download, toTop: Boolean) { fun onStatusChange(download: Download) {
val (selectedSeries, otherSeries) = state.value when (download.status) {
.flatMap { it.downloads } Download.State.DOWNLOADING -> {
.partition { it.manga.id == download.manga.id } launchProgressJob(download)
reorder(if (toTop) selectedSeries + otherSeries else otherSeries + selectedSeries) // Initial update of the downloaded pages
} onUpdateDownloadedPages(download)
}
fun cancelSeries(download: Download) { Download.State.DOWNLOADED -> {
val allDownloadsForSeries = state.value cancelProgressJob(download)
.flatMap { it.downloads } onUpdateProgress(download)
.filter { it.manga.id == download.manga.id } onUpdateDownloadedPages(download)
if (allDownloadsForSeries.isNotEmpty()) { }
cancel(allDownloadsForSeries) Download.State.ERROR -> cancelProgressJob(download)
else -> {
/* unused */
}
} }
} }
/** /**
* Sorts the downloads of every section by [selector]. * Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
*/ */
fun <R : Comparable<R>> reorderQueue(selector: (Download) -> R?, reverse: Boolean = false) { private fun launchProgressJob(download: Download) {
reorder( val job = viewModelScope.launch {
state.value.flatMap { section -> val pages = download.pagesFlow.filterNotNull().first()
section.downloads.sortedBy(selector).let { if (reverse) it.reversed() else it } 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
} }
} }
/**
* Downloads of the queue from one source.
*/
@Immutable
data class DownloadQueueSection(
val id: Long,
val name: String,
val downloads: List<Download>,
)
@@ -80,6 +80,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition 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.download.DownloadCache
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
@@ -98,6 +99,7 @@ import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
import eu.kanade.tachiyomi.util.system.updaterEnabled import eu.kanade.tachiyomi.util.system.updaterEnabled
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -113,7 +115,6 @@ import mihon.app.di.AppGraph
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
import mihon.core.migration.Migrator import mihon.core.migration.Migrator
import mihon.data.cache.ChapterCache
import mihon.domain.database.repository.DatabaseRepository import mihon.domain.database.repository.DatabaseRepository
import mihon.feature.support.SupportUsScreen import mihon.feature.support.SupportUsScreen
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
@@ -324,7 +325,9 @@ class MainActivity : BaseActivity() {
setSplashScreenExitAnimation(splashScreen) setSplashScreenExitAnimation(splashScreen)
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) { if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
lifecycleScope.launch { chapterCache.clear() } lifecycleScope.launch(Dispatchers.IO) {
chapterCache.clear()
}
} }
} }
@@ -414,7 +414,10 @@ class MangaViewModel(
if (isFavorited) { if (isFavorited) {
// Remove from library // Remove from library
if (updateManga.awaitUpdateFavorite(manga.id, false)) { if (updateManga.awaitUpdateFavorite(manga.id, false)) {
manga.removeCovers(coverCache) // Remove covers and update last modified in db
if (manga.removeCovers(coverCache) != manga) {
updateManga.awaitUpdateCoverLastModified(manga.id)
}
onRemoved() onRemoved()
} }
} else { } else {
@@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -105,15 +104,9 @@ data class TrackInfoDialogHomeScreen(
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val locale = LocalLocale.current
val viewModel = assistedMetroViewModel<Model, Model.Factory> { create(mangaId = mangaId, sourceId = sourceId) } val viewModel = assistedMetroViewModel<Model, Model.Factory> { create(mangaId = mangaId, sourceId = sourceId) }
val dateFormat = remember { val dateFormat = remember { UiPreferences.dateFormat(context.appGraph.uiPreferences.dateFormat.get()) }
UiPreferences.dateFormat(
context.appGraph.uiPreferences.dateFormat.get(),
locale.platformLocale,
)
}
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
TrackInfoDialogHome( TrackInfoDialogHome(
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.more package eu.kanade.tachiyomi.ui.more
import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -22,7 +23,6 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.MoreScreen import eu.kanade.presentation.more.MoreScreen
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.presentation.util.rememberAnimatedVectorPainter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -45,12 +45,11 @@ data object MoreTab : Tab {
@Composable @Composable
get() { get() {
val isSelected = LocalTabNavigator.current.current.key == key val isSelected = LocalTabNavigator.current.current.key == key
val enter = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_enter) val image = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more)
val exit = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_exit)
return TabOptions( return TabOptions(
index = 4u, index = 4u,
title = stringResource(MR.strings.label_more), title = stringResource(MR.strings.label_more),
icon = rememberAnimatedVectorPainter(enter, exit, isSelected), icon = rememberAnimatedVectorPainter(image, isSelected),
) )
} }
@@ -1,75 +1,56 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle 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.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding 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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.Insets
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.transition.doOnEnd
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.initializer import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import androidx.lifecycle.viewmodel.viewModelFactory import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.reader.DisplayRefreshHost import eu.kanade.presentation.reader.DisplayRefreshHost
import eu.kanade.presentation.reader.OrientationSelectDialog import eu.kanade.presentation.reader.OrientationSelectDialog
import eu.kanade.presentation.reader.ReaderContentOverlay 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.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReaderPageIndicator import eu.kanade.presentation.reader.ReaderPageIndicator
import eu.kanade.presentation.reader.ReadingModeSelectDialog import eu.kanade.presentation.reader.ReadingModeSelectDialog
@@ -77,8 +58,10 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.components.ChapterNavigatorType import eu.kanade.presentation.reader.components.ChapterNavigatorType
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@@ -92,10 +75,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
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.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.readerBackgroundColor import eu.kanade.tachiyomi.util.system.readerBackgroundColor
@@ -103,13 +84,14 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
@@ -118,8 +100,9 @@ import tachiyomi.core.common.Constants
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
import java.io.ByteArrayOutputStream
import kotlin.time.Duration.Companion.seconds
class ReaderActivity : BaseActivity() { class ReaderActivity : BaseActivity() {
@@ -139,26 +122,23 @@ class ReaderActivity : BaseActivity() {
@Inject private lateinit var preferences: BasePreferences @Inject private lateinit var preferences: BasePreferences
lateinit var binding: ReaderActivityBinding
val viewModel by viewModels<ReaderViewModel> { graph.viewModelFactory } val viewModel by viewModels<ReaderViewModel> { graph.viewModelFactory }
val settingsViewModel by viewModels<ReaderSettingsViewModel> {
viewModelFactory { initializer { ReaderSettingsViewModel(viewModel, readerPreferences, preferences) } }
}
private var assistUrl: String? = null 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 menuToggleToast: Toast? = null
private var readingModeToast: Toast? = null private var readingModeToast: Toast? = null
private val displayRefreshHost = DisplayRefreshHost() private val displayRefreshHost = DisplayRefreshHost()
private val navigationOverlayState = ReaderNavigationOverlayState()
private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, window.decorView) } private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, window.decorView) }
private var isLoadingViewer by mutableStateOf(false) private var loadingIndicator: ReaderProgressIndicator? = null
/**
* 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 var isScrollingThroughPages = false
private set private set
@@ -188,140 +168,9 @@ class ReaderActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setComposeContent { binding = ReaderActivityBinding.inflate(layoutInflater)
val state by viewModel.state.collectAsState() setContentView(binding.root)
val general by settingsViewModel.general.collectAsState() binding.setComposeOverlay()
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) { if (!viewModel.hasValidArgs) {
finish() finish()
@@ -334,6 +183,7 @@ class ReaderActivity : BaseActivity() {
Notifications.ID_NEW_CHAPTERS, Notifications.ID_NEW_CHAPTERS,
) )
config = ReaderConfig()
setMenuVisibility(viewModel.state.value.menuVisible) setMenuVisibility(viewModel.state.value.menuVisible)
// Finish when incognito mode is disabled // Finish when incognito mode is disabled
@@ -349,6 +199,12 @@ class ReaderActivity : BaseActivity() {
.onEach(::setInitialChapterError) .onEach(::setInitialChapterError)
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
.onEach(::setProgressDialog)
.launchIn(lifecycleScope)
viewModel.state viewModel.state
.map { it.manga } .map { it.manga }
.distinctUntilChanged() .distinctUntilChanged()
@@ -392,12 +248,100 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
private fun ReaderActivityBinding.setComposeOverlay(): Unit = composeOverlay.setComposeContent {
val state by viewModel.state.collectAsState()
val showPageNumber by readerPreferences.showPageNumber.collectAsState()
val settingsviewModel = remember {
ReaderSettingsViewModel(
readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
preferences = readerPreferences,
)
}
Box(modifier = Modifier.fillMaxSize()) {
if (!state.menuVisible && showPageNumber) {
ReaderPageIndicator(
currentPage = state.currentPage,
totalPages = state.totalPages,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding(),
)
}
ContentOverlay(state = state)
AppBars(state = state)
}
val onDismissRequest = viewModel::closeDialog
when (state.dialog) {
is ReaderViewModel.Dialog.Loading -> {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text(stringResource(MR.strings.loading))
}
},
)
}
is ReaderViewModel.Dialog.Settings -> {
ReaderSettingsDialog(
onDismissRequest = onDismissRequest,
onShowMenus = { setMenuVisibility(true) },
onHideMenus = { setMenuVisibility(false) },
viewModel = settingsviewModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsviewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode.get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationSelectDialog(
onDismissRequest = onDismissRequest,
viewModel = settingsviewModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
)
}
null -> {}
}
}
/** /**
* Called when the activity is destroyed. Cleans up the viewer and any view. * Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
*/ */
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
viewer?.destroy() viewModel.state.value.viewer?.destroy()
config = null
menuToggleToast?.cancel() menuToggleToast?.cancel()
readingModeToast?.cancel() readingModeToast?.cancel()
} }
@@ -452,97 +396,59 @@ class ReaderActivity : BaseActivity() {
} }
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_N) {
loadNextChapter()
return true
} else if (keyCode == KeyEvent.KEYCODE_P) {
loadPreviousChapter()
return true
}
return super.onKeyUp(keyCode, event)
}
/**
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handled = viewModel.state.value.viewer?.handleKeyEvent(event) ?: false
return handled || super.dispatchKeyEvent(event)
}
/**
* Dispatches a generic motion event. If the viewer doesn't handle it, call the default
* implementation.
*/
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
val handled = viewModel.state.value.viewer?.handleGenericMotionEvent(event) ?: false
return handled || super.dispatchGenericMotionEvent(event)
}
@Composable @Composable
private fun ReaderViewer() { private fun ContentOverlay(state: ReaderViewModel.State) {
val viewer by viewerFlow.collectAsState() val flashOnPageChange by readerPreferences.flashOnPageChange.collectAsState()
val general by settingsViewModel.general.collectAsState()
val insets = when { val colorOverlayEnabled by readerPreferences.colorFilter.collectAsState()
!general.fullscreen -> WindowInsets.systemBars val colorOverlay by readerPreferences.colorFilterValue.collectAsState()
!general.drawUnderCutout -> WindowInsets.displayCutout val colorOverlayMode by readerPreferences.colorFilterMode.collectAsState()
else -> WindowInsets(0) val colorOverlayBlendMode = remember(colorOverlayMode) {
} ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
val colorFilter = remember(general.grayscale, general.invertedColors) {
viewerColorFilter(general.grayscale, general.invertedColors)
} }
// Each viewer filters its page images alone: transition, loading and error pages keep their colours. ReaderContentOverlay(
CompositionLocalProvider(LocalPageColorFilter provides colorFilter) { brightness = state.brightnessOverlayValue,
key(viewer) { color = colorOverlay.takeIf { colorOverlayEnabled },
viewer?.Content( colorBlendMode = colorOverlayBlendMode,
modifier = Modifier )
.fillMaxSize()
.windowInsetsPadding(insets),
)
}
}
}
/** if (flashOnPageChange) {
* Handles a key [event] before anything else in the reader. Movement happens on key up. DisplayRefreshHost(hostState = displayRefreshHost)
*/
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 @Composable
fun AppBars(state: ReaderViewModel.State) { fun AppBars(state: ReaderViewModel.State) {
val isHttpSource = state.source is HttpSource val isHttpSource = state.source is HttpSource
val readingMode = ReadingMode.fromPreference(viewModel.getMangaReadingMode())
val cropBorderPaged by readerPreferences.cropBorders.collectAsState() val cropBorderPaged by readerPreferences.cropBorders.collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState() val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState()
@@ -550,7 +456,9 @@ class ReaderActivity : BaseActivity() {
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState() val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState()
val verticalNavigator = verticalNavigatorModes.contains(readingMode) val verticalNavigator = verticalNavigatorModes.contains(
ReadingMode.fromPreference(viewModel.getMangaReadingMode()),
)
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState() val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState() val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState()
@@ -568,7 +476,7 @@ class ReaderActivity : BaseActivity() {
onShare = ::shareChapter.takeIf { isHttpSource }, onShare = ::shareChapter.takeIf { isHttpSource },
chapterNavigatorType = if (!verticalNavigator) { chapterNavigatorType = if (!verticalNavigator) {
if (readingMode == ReadingMode.RIGHT_TO_LEFT) { if (state.viewer is R2LPagerViewer) {
ChapterNavigatorType.HORIZONTAL_RTL ChapterNavigatorType.HORIZONTAL_RTL
} else { } else {
ChapterNavigatorType.HORIZONTAL_LTR ChapterNavigatorType.HORIZONTAL_LTR
@@ -629,22 +537,35 @@ class ReaderActivity : BaseActivity() {
* Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer. * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer.
*/ */
private fun updateViewer() { private fun updateViewer() {
val prevViewer = viewer val prevViewer = viewModel.state.value.viewer
val readingMode = viewModel.getMangaReadingMode() val newViewer = ReadingMode.toViewer(viewModel.getMangaReadingMode(), this)
settingsViewModel.setViewerReadingMode(ReadingMode.fromPreference(readingMode))
val newViewer = ReadingMode.toViewer(readingMode, this)
setOrientation(viewModel.getMangaOrientation()) if (window.sharedElementEnterTransition is MaterialContainerTransform) {
// Wait until transition is complete to avoid crash on API 26
window.sharedElementEnterTransition.doOnEnd {
setOrientation(viewModel.getMangaOrientation())
}
} else {
setOrientation(viewModel.getMangaOrientation())
}
// Destroy previous viewer if there was one // Destroy previous viewer if there was one
prevViewer?.destroy() if (prevViewer != null) {
viewerFlow.value = newViewer prevViewer.destroy()
binding.viewerContainer.removeAllViews()
}
viewModel.onViewerLoaded(newViewer)
updateViewerInset(readerPreferences.fullscreen.get(), readerPreferences.drawUnderCutout.get())
binding.viewerContainer.addView(newViewer.getView())
if (readerPreferences.showReadingMode.get()) { if (readerPreferences.showReadingMode.get()) {
showReadingModeToast(viewModel.getMangaReadingMode()) showReadingModeToast(viewModel.getMangaReadingMode())
} }
isLoadingViewer = true loadingIndicator = ReaderProgressIndicator(this)
binding.readerContainer.addView(loadingIndicator)
startPostponedEnterTransition()
} }
private fun openMangaScreen() { private fun openMangaScreen() {
@@ -695,9 +616,10 @@ class ReaderActivity : BaseActivity() {
* method to the current viewer, but also set the subtitle on the toolbar, and * 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 * hides or disables the reader prev/next buttons if there's a prev or next chapter
*/ */
@SuppressLint("RestrictedApi")
private fun setChapters(viewerChapters: ViewerChapters) { private fun setChapters(viewerChapters: ViewerChapters) {
isLoadingViewer = false binding.readerContainer.removeView(loadingIndicator)
viewer?.setChapters(viewerChapters) viewModel.state.value.viewer?.setChapters(viewerChapters)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
viewModel.getChapterUrl()?.let { url -> viewModel.getChapterUrl()?.let { url ->
@@ -716,12 +638,26 @@ class ReaderActivity : BaseActivity() {
toast(error.message) 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 * Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
* page is not found. * page is not found.
*/ */
private fun moveToPageIndex(index: Int) { private fun moveToPageIndex(index: Int) {
val viewer = this.viewer ?: return val viewer = viewModel.state.value.viewer ?: return
val currentChapter = viewModel.state.value.currentChapter ?: return val currentChapter = viewModel.state.value.currentChapter ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return val page = currentChapter.pages?.getOrNull(index) ?: return
viewer.moveToPage(page) viewer.moveToPage(page)
@@ -860,41 +796,172 @@ class ReaderActivity : BaseActivity() {
} }
/** /**
* Sets the brightness of the screen. Range is [-75, 100]. * Updates viewer inset depending on fullscreen reader preferences.
* 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 setWindowBrightness(value: Int) { private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
// Calculate and set reader brightness. if (!::binding.isInitialized) return
val readerBrightness = when { val view = binding.viewerContainer
value > 0 -> {
value / 100f
}
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
}
private fun viewerColorFilter(grayscale: Boolean, invertedColors: Boolean): ColorFilter? { view.applyInsetsPadding(ViewCompat.getRootWindowInsets(view), fullscreen, drawUnderCutout)
if (!grayscale && !invertedColors) return null ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
val matrix = ColorMatrix() view.applyInsetsPadding(windowInsets, fullscreen, drawUnderCutout)
if (invertedColors) { windowInsets
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) } private fun View.applyInsetsPadding(
windowInsets: WindowInsetsCompat?,
fullscreen: Boolean,
drawUnderCutout: Boolean,
) {
val insets = when {
!fullscreen -> windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars())
!drawUnderCutout -> windowInsets?.getInsets(WindowInsetsCompat.Type.displayCutout())
else -> null
}
?: Insets.NONE
setPadding(insets.left, insets.top, insets.right, insets.bottom)
}
/**
* Class that handles the user preferences of the reader.
*/
private inner class ReaderConfig {
private fun getCombinedPaint(grayscale: Boolean, invertedColors: Boolean): Paint {
return Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix().apply {
if (grayscale) {
setSaturation(0f)
}
if (invertedColors) {
postConcat(
ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f,
),
),
)
}
},
)
}
}
/*
* Initializes the reader subscriptions.
*/
init {
readerPreferences.readerTheme.changes()
.onEach { theme ->
binding.readerContainer.setBackgroundColor(baseContext.readerBackgroundColor(theme))
}
.launchIn(lifecycleScope)
preferences.displayProfile.changes()
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn.changes()
.onEach(::setKeepScreenOn)
.launchIn(lifecycleScope)
readerPreferences.customBrightness.changes()
.onEach(::setCustomBrightness)
.launchIn(lifecycleScope)
combine(
readerPreferences.grayscale.changes(),
readerPreferences.invertedColors.changes(),
) { grayscale, invertedColors -> grayscale to invertedColors }
.onEach { (grayscale, invertedColors) ->
setLayerPaint(grayscale, invertedColors)
}
.launchIn(lifecycleScope)
combine(
readerPreferences.fullscreen.changes(),
readerPreferences.drawUnderCutout.changes(),
) { fullscreen, drawUnderCutout -> fullscreen to drawUnderCutout }
.onEach { (fullscreen, drawUnderCutout) ->
updateViewerInset(fullscreen, drawUnderCutout)
}
.launchIn(lifecycleScope)
}
/**
* Sets the display profile to [path].
*/
private fun setDisplayProfile(path: String) {
val file = UniFile.fromUri(baseContext, path.toUri())
if (file != null && file.exists()) {
val inputStream = file.openInputStream()
val outputStream = ByteArrayOutputStream()
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
val data = outputStream.toByteArray()
SubsamplingScaleImageView.setDisplayProfile(data)
ImageDecoder.displayProfile = data
}
}
/**
* Sets the keep screen on mode according to [enabled].
*/
private fun setKeepScreenOn(enabled: Boolean) {
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* Sets the custom brightness overlay according to [enabled].
*/
private fun setCustomBrightness(enabled: Boolean) {
if (enabled) {
readerPreferences.customBrightnessValue.changes()
.sample(0.1.seconds)
.onEach(::setCustomBrightnessValue)
.launchIn(lifecycleScope)
} else {
setCustomBrightnessValue(0)
}
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
* From 1 to 100 it sets that value as brightness.
* 0 sets system brightness and hides the overlay.
*/
private fun setCustomBrightnessValue(value: Int) {
// Calculate and set reader brightness.
val readerBrightness = when {
value > 0 -> {
value / 100f
}
value < 0 -> {
0.01f
}
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
viewModel.setBrightnessOverlayValue(value)
}
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
val paint = if (grayscale || invertedColors) getCombinedPaint(grayscale, invertedColors) else null
binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint)
}
} }
return ColorFilter.colorMatrix(matrix)
} }
@@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.graphics.withScale
import androidx.core.graphics.withTranslation
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import tachiyomi.core.common.i18n.stringResource
import kotlin.math.abs
class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private var viewPropertyAnimator: ViewPropertyAnimator? = null
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
invalidate()
if (isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
viewPropertyAnimator = animate()
.alpha(1f)
.setDuration(FADE_DURATION)
.withStartAction {
isVisible = true
}
.withEndAction {
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
private val regionPaint = Paint()
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.WHITE
textSize = 64f
}
private val textBorderPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
textSize = 64f
style = Paint.Style.STROKE
strokeWidth = 8f
}
override fun onDraw(canvas: Canvas) {
if (navigation == null) return
navigation?.getRegions()?.forEach { region ->
val rect = region.rectF
// Scale rect from 1f,1f to screen width and height
canvas.withScale(width.toFloat(), height.toFloat()) {
regionPaint.color = region.type.color
drawRect(rect, regionPaint)
}
// Don't want scale anymore because it messes with drawText
// Translate origin to rect start (left, top)
canvas.withTranslation(x = (width * rect.left), y = (height * rect.top)) {
// Calculate center of rect width on screen
val x = width * (abs(rect.left - rect.right) / 2)
// Calculate center of rect height on screen
val y = height * (abs(rect.top - rect.bottom) / 2)
drawText(context.stringResource(region.type.nameRes), x, y, textBorderPaint)
drawText(context.stringResource(region.type.nameRes), x, y, textPaint)
}
}
}
override fun performClick(): Boolean {
super.performClick()
if (viewPropertyAnimator == null && isVisible) {
viewPropertyAnimator = animate()
.alpha(0f)
.setDuration(FADE_DURATION)
.withEndAction {
isVisible = false
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
return true
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
// Hide overlay if user start tapping or swiping
performClick()
return super.onTouchEvent(event)
}
}
private const val FADE_DURATION = 1000L
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.annotation.IntRange
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -24,6 +25,7 @@ import eu.kanade.domain.manga.model.readingMode
import eu.kanade.domain.source.interactor.GetIncognitoState import eu.kanade.domain.source.interactor.GetIncognitoState
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import eu.kanade.domain.track.service.TrackPreferences 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.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -45,6 +47,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode 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.filterDownloaded
import eu.kanade.tachiyomi.util.chapter.removeDuplicates import eu.kanade.tachiyomi.util.chapter.removeDuplicates
import eu.kanade.tachiyomi.util.editCover import eu.kanade.tachiyomi.util.editCover
@@ -75,7 +78,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import mihon.data.cache.ChapterCache
import tachiyomi.core.common.preference.toggle import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -525,6 +527,12 @@ class ReaderViewModel(
eventChannel.trySend(Event.ReloadViewerChapters) 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 * 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 * read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this
@@ -912,6 +920,10 @@ class ReaderViewModel(
mutableState.update { it.copy(menuVisible = visible) } mutableState.update { it.copy(menuVisible = visible) }
} }
fun showLoadingDialog() {
mutableState.update { it.copy(dialog = Dialog.Loading) }
}
fun openReadingModeSelectDialog() { fun openReadingModeSelectDialog() {
mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) } mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) }
} }
@@ -932,6 +944,10 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) } 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. * 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. * There's also a notification to allow sharing the image somewhere else or deleting it.
@@ -1094,8 +1110,14 @@ class ReaderViewModel(
val bookmarked: Boolean = false, val bookmarked: Boolean = false,
val isLoadingAdjacentChapter: Boolean = false, val isLoadingAdjacentChapter: Boolean = false,
val currentPage: Int = -1, val currentPage: Int = -1,
/**
* Viewer used to display the pages (pager, webtoon, ...).
*/
val viewer: Viewer? = null,
val dialog: Dialog? = null, val dialog: Dialog? = null,
val menuVisible: Boolean = false, val menuVisible: Boolean = false,
@IntRange(from = -100, to = 100) val brightnessOverlayValue: Int = 0,
) { ) {
val currentChapter: ReaderChapter? val currentChapter: ReaderChapter?
get() = viewerChapters?.currChapter get() = viewerChapters?.currChapter
@@ -1105,6 +1127,7 @@ class ReaderViewModel(
} }
sealed interface Dialog { sealed interface Dialog {
data object Loading : Dialog
data object Settings : Dialog data object Settings : Dialog
data object ReadingModeSelect : Dialog data object ReadingModeSelect : Dialog
data object OrientationModeSelect : Dialog data object OrientationModeSelect : Dialog
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context import android.content.Context
import eu.kanade.domain.chapter.model.toSChapter 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.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
@@ -12,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.core.archive.archiveReader import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader import mihon.core.archive.epubReader
import mihon.data.cache.ChapterCache
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.domain.chapter.model.toSChapter 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.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@@ -15,8 +16,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.data.cache.ChapterCache
import java.io.FileNotFoundException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -42,8 +41,14 @@ internal class HttpPageLoader(
* otherwise fallbacks to network. * otherwise fallbacks to network.
*/ */
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val pages = chapterCache.getPageList(chapter.chapter) val pages = try {
?: source.getPageList(chapter.chapter.toSChapter()) chapterCache.getPageListFromCache(chapter.chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
source.getPageList(chapter.chapter.toSChapter())
}
return pages.mapIndexed { index, page -> return pages.mapIndexed { index, page ->
// Don't trust sources and use our own indexing // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl).also { ReaderPage(index, page.url, page.imageUrl).also {
@@ -109,7 +114,7 @@ internal class HttpPageLoader(
try { try {
// Convert to pages without reader information // Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageList(chapter.chapter, pagesToSave) chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) { if (e is CancellationException) {
throw e throw e
@@ -225,32 +230,15 @@ internal class HttpPageLoader(
} }
val imageUrl = page.imageUrl!! val imageUrl = page.imageUrl!!
val file = chapterCache.getImageFile(imageUrl).takeUnless { force } ?: run { if (force || !chapterCache.isImageInCache(imageUrl)) {
val live = page.downloadStream
page.status = Page.State.DownloadImage page.status = Page.State.DownloadImage
try { val imageResponse = source.getImage(page)
chapterCache.putImage(imageUrl, source.getImage(page), live) chapterCache.putImageToCache(imageUrl, imageResponse)
.also { live?.finish() }
} catch (e: Throwable) {
live?.finish(e)
throw e
}
} }
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 page.status = Page.State.Ready
} catch (e: Throwable) { } catch (e: Throwable) {
page.downloadStream = null
if (e is CancellationException) { if (e is CancellationException) {
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in // Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
// the queue, otherwise it's stuck in a state no later pass will pick up. // the queue, otherwise it's stuck in a state no later pass will pick up.
@@ -1,130 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.model
import java.io.IOException
import java.io.InputStream
/**
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
*/
class DownloadStream {
private val lock = Object()
private var data = ByteArray(1 shl 16)
private var size = 0
private var done = false
private var error: Throwable? = null
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
fun reserve(bytes: Long) {
synchronized(lock) {
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
try {
data = data.copyOf(bytes.toInt())
} catch (e: OutOfMemoryError) {
// Only a hint.
}
}
}
}
fun append(src: ByteArray, offset: Int, length: Int) {
synchronized(lock) {
if (error != null || done) return
val needed = size.toLong() + length
if (needed > MAX_BYTES) {
// Too big to decode while downloading; the whole-file decode takes over.
drop("Download too large to stream")
return
}
if (needed > data.size) {
data = try {
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
} catch (e: OutOfMemoryError) {
// As too large: the download mustn't fail over this copy.
drop("Out of memory buffering the download")
return
}
}
System.arraycopy(src, offset, data, size, length)
size += length
lock.notifyAll()
}
}
/** Ends the bytes; readers throw [error] if given. */
fun finish(error: Throwable? = null) {
synchronized(lock) {
done = true
if (this.error == null) this.error = error
lock.notifyAll()
}
}
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
fun reader(): Reader = Reader()
// Rather than hold a second copy of the page.
private fun drop(why: String) {
if (error == null) error = IOException(why)
data = ByteArray(0)
size = 0
lock.notifyAll()
}
private companion object {
// As the decoder's own input limit.
const val MAX_BYTES = 1L shl 30
const val MAX_RESERVE = 64L shl 20
}
inner class Reader : InputStream() {
private var position = 0
/** All read; throws if the download failed. */
val ended: Boolean
get() = synchronized(lock) {
checkError()
done && position >= size
}
override fun read(): Int {
val b = ByteArray(1)
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
synchronized(lock) {
if (len == 0) return 0
while (position >= size && !done && error == null) lock.wait()
checkError()
if (position >= size) return -1
val n = minOf(len, size - position)
System.arraycopy(data, position, b, off, n)
position += n
return n
}
}
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
fun await(min: Int, timeoutMs: Long) {
synchronized(lock) {
val until = System.nanoTime() + timeoutMs * 1_000_000
while (size - position < min && !done && error == null) {
val left = (until - System.nanoTime()) / 1_000_000
if (left <= 0) return
lock.wait(left)
}
}
}
override fun close() {
synchronized(lock) { drop("No longer read") }
}
private fun checkError() {
error?.let { throw IOException("Download failed", it) }
}
}
}
@@ -11,8 +11,4 @@ open class ReaderPage(
) : Page(index, url, imageUrl, null) { ) : Page(index, url, imageUrl, null) {
open lateinit var chapter: ReaderChapter open lateinit var chapter: ReaderChapter
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
@Volatile
var downloadStream: DownloadStream? = null
} }
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.setting
/**
* Settings for the reader as a whole and for every viewer alike.
*/
data class GeneralSettings(
val theme: ReaderPreferences.ReaderTheme,
val keepScreenOn: Boolean,
val grayscale: Boolean,
val invertedColors: Boolean,
val fullscreen: Boolean,
val drawUnderCutout: Boolean,
val longTapEnabled: Boolean,
val usePageTransitions: Boolean,
val doubleTapAnimDuration: Int,
val volumeKeysEnabled: Boolean,
val volumeKeysInverted: Boolean,
val alwaysShowChapterTransition: Boolean,
val navigationOverlayOnStart: Boolean,
) {
val automaticBackground: Boolean
get() = theme == ReaderPreferences.ReaderTheme.AUTOMATIC
}
@@ -6,8 +6,6 @@ import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn 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.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnum
@@ -144,11 +142,6 @@ class ReaderPreferences(
false, false,
) )
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
"pref_dual_page_view",
DualPageView.NEVER,
)
// endregion // endregion
// region Color filter // region Color filter
@@ -206,43 +199,6 @@ class ReaderPreferences(
// endregion // endregion
// region WebGpu
val transitionAnimation: Preference<TransitionAnimation> =
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC)
val transitionAnimationDual: Preference<TransitionAnimation> =
preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC)
val cutoutMode: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID)
val cutoutModeDual: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE)
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
val disableDoubleTapGestures: Preference<Boolean> =
preferenceStore.getBoolean("webgpu_disable_double_tap_gestures", false)
/** In run order. */
val imageFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_image_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
/** In run order. */
val tileFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_tile_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
// endregion
enum class FlashColor { enum class FlashColor {
BLACK, BLACK,
WHITE, WHITE,
@@ -255,81 +211,11 @@ class ReaderPreferences(
val shouldInvertVertical: Boolean = false, val shouldInvertVertical: Boolean = false,
) { ) {
NONE(MR.strings.tapping_inverted_none), NONE(MR.strings.tapping_inverted_none),
HORIZONTAL( HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true),
MR.strings.tapping_inverted_horizontal, VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true),
shouldInvertHorizontal = true,
),
VERTICAL(
MR.strings.tapping_inverted_vertical,
shouldInvertVertical = true,
),
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true), BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
} }
/**
* The [readerTheme] preference, stored as its [value].
*/
enum class ReaderTheme(val value: Int) {
WHITE(0),
BLACK(1),
GRAY(2),
AUTOMATIC(3),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: BLACK
}
}
/**
* The [imageScaleType] preference, stored as its [value], which is also the scale type the image view takes.
*/
enum class ScaleType(val value: Int) {
FIT_SCREEN(1),
STRETCH(2),
FIT_WIDTH(3),
FIT_HEIGHT(4),
ORIGINAL_SIZE(5),
SMART_FIT(6),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: FIT_SCREEN
}
}
/**
* The [zoomStart] preference, stored as its [value].
*/
enum class ZoomStartType(val value: Int) {
AUTOMATIC(1),
LEFT(2),
RIGHT(3),
CENTER(4),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: CENTER
}
}
/**
* The [navigationModePager] and [navigationModeWebtoon] preferences, stored as their [value].
*/
enum class NavigationMode(val value: Int) {
DEFAULT(0),
L_SHAPED(1),
KINDLISH(2),
EDGE(3),
RIGHT_AND_LEFT(4),
DISABLED(5),
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: DEFAULT
}
}
enum class ReaderHideThreshold(val threshold: Int) { enum class ReaderHideThreshold(val threshold: Int) {
HIGHEST(5), HIGHEST(5),
HIGH(13), HIGH(13),
@@ -337,43 +223,6 @@ class ReaderPreferences(
LOWEST(47), LOWEST(47),
} }
enum class TransitionAnimation(val titleRes: StringResource) {
BASIC(MR.strings.transition_animation_basic),
FLIP(MR.strings.transition_animation_flip),
FLIP_LEFT(MR.strings.transition_animation_flip_left),
FLIP_RIGHT(
MR.strings.transition_animation_flip_right,
),
STACK_LEFT(MR.strings.transition_animation_stack_left),
STACK_RIGHT(MR.strings.transition_animation_stack_right),
STACK_UP(
MR.strings.transition_animation_stack_up,
),
STACK_DOWN(MR.strings.transition_animation_stack_down),
SPHERE(MR.strings.transition_animation_sphere),
CUBE_INSIDE(
MR.strings.transition_animation_cube_inside,
),
CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside),
FADE(MR.strings.transition_animation_fade),
FADE_WHITE(
MR.strings.transition_animation_fade_white,
),
NONE(MR.strings.transition_animation_none),
}
enum class CutoutMode(val titleRes: StringResource) {
IGNORE(MR.strings.cutout_mode_ignore),
AVOID(MR.strings.cutout_mode_avoid),
SHIFT(MR.strings.cutout_mode_shift),
}
enum class DualPageView(val titleRes: StringResource) {
NEVER(MR.strings.dual_page_view_never),
ALWAYS(MR.strings.dual_page_view_always),
WIDE(MR.strings.dual_page_view_wide),
}
companion object { companion object {
const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25 const val WEBTOON_PADDING_MAX = 25
@@ -398,13 +247,6 @@ class ReaderPreferences(
MR.strings.scale_type_smart_fit, MR.strings.scale_type_smart_fit,
) )
val ImageScaleTypeWebGpuViewer = listOf(
MR.strings.scale_type_fit_screen,
MR.strings.scale_type_fit_width,
MR.strings.scale_type_fit_height,
MR.strings.scale_type_original_size,
)
val ZoomStart = listOf( val ZoomStart = listOf(
MR.strings.zoom_start_automatic, MR.strings.zoom_start_automatic,
MR.strings.zoom_start_left, MR.strings.zoom_start_left,
@@ -2,317 +2,27 @@ package eu.kanade.tachiyomi.ui.reader.setting
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn 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( class ReaderSettingsViewModel(
private val readerViewModel: ReaderViewModel, readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit,
val preferences: ReaderPreferences, val preferences: ReaderPreferences,
private val basePreferences: BasePreferences,
) : ViewModel() { ) : ViewModel() {
val mangaFlow = readerViewModel.state val viewerFlow = readerState
.map { it.manga } .map { it.viewer }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Lazily, null) .stateIn(viewModelScope, SharingStarted.Lazily, null)
/** val mangaFlow = readerState
* The reading mode the viewer is made for, with the default resolved. .map { it.manga }
*/ .distinctUntilChanged()
val readingMode = combine(mangaFlow, preferences.defaultReadingMode.changes()) { manga, default -> .stateIn(viewModelScope, SharingStarted.Lazily, null)
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,
)
} }
@@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
@@ -77,16 +74,6 @@ enum class ReadingMode(
} }
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer { fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
if (activity.appGraph.basePreferences.highQualityRenderer.get()) {
return when (fromPreference(preference)) {
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
}
}
return when (fromPreference(preference)) { return when (fromPreference(preference)) {
LEFT_TO_RIGHT -> L2RPagerViewer(activity) LEFT_TO_RIGHT -> L2RPagerViewer(activity)
RIGHT_TO_LEFT -> R2LPagerViewer(activity) RIGHT_TO_LEFT -> R2LPagerViewer(activity)
@@ -1,11 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* How a viewer shows a wide page.
*/
interface DualPageSettings {
val dualPageSplit: Boolean
val dualPageInvert: Boolean
val dualPageRotateToFit: Boolean
val dualPageRotateToFitInvert: Boolean
}
@@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* Settings every long strip viewer uses.
*/
data class LongStripSettings(
val cropBorders: Boolean,
val zoomOutDisabled: Boolean,
)
@@ -1,89 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/**
* The image of [page] as a pager shows it: a wide page rotated to fit or split in two, the half on the reading
* side first, so [isLeftToRight] tells which. [onPageSplit] is told of the [InsertPage] for the second half.
*/
fun DualPageSettings.processPagedImage(
page: ReaderPage,
imageSource: BufferedSource,
isLeftToRight: Boolean,
onPageSplit: (InsertPage) -> Unit,
): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (!dualPageSplit) {
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageSource, page, isLeftToRight)
}
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageSource
}
onPageSplit(InsertPage(page))
return splitInHalf(imageSource, page, isLeftToRight)
}
/**
* The image of a page as a strip shows it: a wide page rotated to fit, or split with its halves one above the other.
*/
fun DualPageSettings.processStripImage(imageSource: BufferedSource): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageSource
}
private fun DualPageSettings.rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
private fun DualPageSettings.splitInHalf(
imageSource: BufferedSource,
page: ReaderPage,
isLeftToRight: Boolean,
): BufferedSource {
var side = when {
isLeftToRight && page is InsertPage -> ImageUtil.Side.RIGHT
!isLeftToRight && page is InsertPage -> ImageUtil.Side.LEFT
isLeftToRight && page !is InsertPage -> ImageUtil.Side.LEFT
else -> ImageUtil.Side.RIGHT
}
if (dualPageInvert) {
side = when (side) {
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
}
}
return ImageUtil.splitInHalf(imageSource, side)
}
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
/**
* Settings every paged viewer uses.
*/
data class PagedSettings(
val page: Page,
val navigateToPan: Boolean,
val navigation: NavigationSettings,
) {
/**
* What the pages are made with, so a change remakes them.
*/
data class Page(
val imageScaleType: ReaderPreferences.ScaleType,
val zoomStart: ReaderPreferences.ZoomStartType,
val cropBorders: Boolean,
val landscapeZoom: Boolean,
)
}
@@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import com.google.android.material.R
import com.google.android.material.button.MaterialButton
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
/**
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
* the pager, but this class disables that behavior to allow clickable buttons.
*/
class ReaderButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.materialButtonStyle,
) : MaterialButton(context, attrs, defStyleAttr) {
var viewer: PagerViewer? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
viewer?.pager?.setGestureDetectorEnabled(false)
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
viewer?.pager?.setGestureDetectorEnabled(true)
}
return super.onTouchEvent(event)
}
}
@@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.reader.ReaderError
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
/**
* [ReaderError] for a [page] that failed with [error]: Retry asks its loader again, and an image from the web can
* be opened in a WebView with the cookies of the source [sourceId].
*/
@Composable
fun ReaderPageError(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val context = LocalContext.current
val imageUrl = page.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
ReaderError(
message = with(context) { error?.formattedMessage },
onRetry = { page.chapter.pageLoader?.retryPage(page) },
onOpenInWebView = imageUrl?.let {
{ context.startActivity(WebViewActivity.newIntent(context, it, sourceId)) }
},
modifier = modifier,
onButtonPressChange = onButtonPressChange,
)
}
/**
* [ReaderPageError] for the view-based viewers, filling the view, in the theme of its reader-themed [context].
*/
class ReaderPageErrorView(context: Context) : AbstractComposeView(context) {
private var data by mutableStateOf<Data?>(null)
fun bind(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
data = Data(page, error, sourceId, onButtonPressChange)
}
@Composable
override fun Content() {
val data = data ?: return
ReaderThemedContent(context) {
ReaderPageError(
page = data.page,
error = data.error,
sourceId = data.sourceId,
modifier = Modifier.fillMaxSize(),
onButtonPressChange = data.onButtonPressChange,
)
}
}
private data class Data(
val page: ReaderPage,
val error: Throwable?,
val sourceId: Long?,
val onButtonPressChange: ((pressed: Boolean) -> Unit)?,
)
}
@@ -1,8 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context import android.content.Context
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PointF import android.graphics.PointF
import android.graphics.RectF import android.graphics.RectF
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
@@ -15,7 +13,6 @@ import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
@@ -44,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/** /**
* A wrapper view for showing page image. * A wrapper view for showing page image.
@@ -63,30 +61,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
private var pageView: View? = null 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 stripRequest: Disposable? = null
private var config: Config? = null private var config: Config? = null
@@ -267,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
removeView(pageView) removeView(pageView)
pageView = SubsamplingScaleImageView(context).apply { pageView = SubsamplingScaleImageView(context).apply {
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumTileDpi(180) setMinimumTileDpi(180)
@@ -284,7 +259,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
setOnClickListener { this@ReaderPageImageView.onViewClicked() } setOnClickListener { this@ReaderPageImageView.onViewClicked() }
} }
addView(pageView, MATCH_PARENT, MATCH_PARENT) addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
} }
private fun SubsamplingScaleImageView.setupZoom(config: Config?) { private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
@@ -328,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
isVisible = true isVisible = true
} }
is BufferedSource -> { is BufferedSource -> {
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
setImage(ImageSource.inputStream(data.inputStream())) setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true isVisible = true
} }
@@ -343,7 +318,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
pageView = WebtoonStripImageView(context) pageView = WebtoonStripImageView(context)
addView(pageView, MATCH_PARENT, WRAP_CONTENT) addView(pageView, MATCH_PARENT, WRAP_CONTENT)
applyPageColorFilter()
} }
private fun setStripImage(data: BufferedSource, config: Config) { private fun setStripImage(data: BufferedSource, config: Config) {
@@ -420,7 +394,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
} }
} }
addView(pageView, MATCH_PARENT, MATCH_PARENT) addView(pageView, MATCH_PARENT, MATCH_PARENT)
applyPageColorFilter()
} }
private fun setAnimatedImage( private fun setAnimatedImage(
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.theme.TachiyomiTheme
/**
* [content] in the app's theme under the reader's own night mode, from [readerThemedContext].
*
* Pages sit on the reader background, so a light app over a black reader would otherwise draw dark text on black.
* [TachiyomiTheme] reads that from the configuration, which the view-based viewers got by inflating their views
* from a reader-themed context.
*/
@Composable
fun ReaderThemedContent(readerThemedContext: Context, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalContext provides readerThemedContext,
LocalConfiguration provides readerThemedContext.resources.configuration,
) {
TachiyomiTheme(content = content)
}
}
@@ -1,86 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.reader.reportPress
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderTransitionPage(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit,
modifier: Modifier = Modifier,
statusPadding: PaddingValues = PaddingValues(),
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (downloadState != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition,
downloadState.currChapterDownloaded,
downloadState.goingToChapterDownloaded,
)
}
}
val toChapter = transition.to ?: return@Column
val state by toChapter.stateFlow.collectAsState()
when (val state = state) {
is ReaderChapter.State.Loading -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = stringResource(MR.strings.transition_pages_loading),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
}
is ReaderChapter.State.Error -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(MR.strings.transition_pages_error, state.error.message ?: ""),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = { onRetry(toChapter) },
modifier = Modifier.reportPress(onButtonPressChange),
) {
Text(stringResource(MR.strings.action_retry))
}
}
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {}
}
}
}
@@ -2,58 +2,91 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
/**
* [ReaderTransitionPage] for the view-based viewers, in the theme of its reader-themed [context].
*/
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
AbstractComposeView(context, attrs) { AbstractComposeView(context, attrs) {
private var data: Data? by mutableStateOf(null) private var data: Data? by mutableStateOf(null)
init { init {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
fun bind( fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
transition: ChapterTransition, data = if (manga != null && source != null) {
downloadState: TransitionDownloadState?, val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
onRetry: (ReaderChapter) -> Unit, Data(
onButtonPressChange: (pressed: Boolean) -> Unit = {}, transition = transition,
statusPadding: PaddingValues = PaddingValues(), currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
) { goingToChapterDownloaded = manga.isLocal() ||
data = Data(transition, downloadState, onRetry, onButtonPressChange, statusPadding) goingToChapter?.let {
downloadManager.isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
downloadManager.isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
} else {
null
}
} }
@Composable @Composable
override fun Content() { override fun Content() {
val data = data ?: return data?.let {
LaunchedEffect(data.downloadState) { data.downloadState?.checkDisk() } val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
ReaderThemedContent(context) { it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
ReaderTransitionPage( }
transition = data.transition, TachiyomiTheme {
downloadState = data.downloadState, CompositionLocalProvider(
onRetry = data.onRetry, LocalTextStyle provides MaterialTheme.typography.bodySmall,
onButtonPressChange = data.onButtonPressChange, LocalContentColor provides MaterialTheme.colorScheme.onBackground,
statusPadding = data.statusPadding, ) {
) ChapterTransition(
transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = goingToChapterDownloaded,
)
}
}
} }
} }
private data class Data( private data class Data(
val transition: ChapterTransition, val transition: ChapterTransition,
val downloadState: TransitionDownloadState?, val currChapterDownloaded: Boolean,
val onRetry: (ReaderChapter) -> Unit, val goingToChapterDownloaded: Boolean,
val onButtonPressChange: (pressed: Boolean) -> Unit, val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
val statusPadding: PaddingValues,
) )
} }
@@ -1,56 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
class TransitionDownloadState(
val currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean,
private val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
) {
var goingToChapterDownloaded by mutableStateOf(goingToChapterDownloaded)
private set
suspend fun checkDisk() {
isGoingToChapterOnDisk?.let { goingToChapterDownloaded = it() }
}
}
fun DownloadManager.transitionDownloadState(
transition: ChapterTransition,
manga: Manga?,
source: Source?,
): TransitionDownloadState? {
if (manga == null || source == null) return null
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
return TransitionDownloadState(
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
goingToChapter?.let {
isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
}
@@ -1,12 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.viewer package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.runtime.Composable import android.view.KeyEvent
import androidx.compose.runtime.compositionLocalOf import android.view.MotionEvent
import androidx.compose.ui.Modifier import android.view.View
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.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@@ -16,10 +12,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
interface Viewer { interface Viewer {
/** /**
* Draws this viewer. * Returns the view this viewer uses.
*/ */
@Composable fun getView(): View
fun Content(modifier: Modifier)
/** /**
* Destroys this viewer. Called when leaving the reader or swapping viewers. * Destroys this viewer. Called when leaving the reader or swapping viewers.
@@ -37,37 +32,14 @@ interface Viewer {
fun moveToPage(page: ReaderPage) fun moveToPage(page: ReaderPage)
/** /**
* Moves forward through the pages. * Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/ */
fun moveForward() fun handleKeyEvent(event: KeyEvent): Boolean
/** /**
* Moves backward through the pages. * Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/ */
fun moveBackward() fun handleGenericMotionEvent(event: MotionEvent): Boolean
}
/**
* 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() }
}
}
} }

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