mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-29 20:21:59 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feadad5e1e | ||
|
|
fbcbe40714 | ||
|
|
ab1f749289 | ||
|
|
f59dabd4d7 | ||
|
|
e8f2c3f988 | ||
|
|
ca8ffacd10 | ||
|
|
036a69c089 | ||
|
|
335c50c92e | ||
|
|
9e3f4c0fec | ||
|
|
0cfc078b00 | ||
|
|
52c348f32b | ||
|
|
42474cd46e | ||
|
|
8780f1ca8a | ||
|
|
b95886a00d | ||
|
|
e5b44d4d34 | ||
|
|
7e930f1afb | ||
|
|
9158e4c9e6 | ||
|
|
5f7dc2b81f | ||
|
|
2a5581df98 | ||
|
|
ba70dd31e9 | ||
|
|
f23a2c374d | ||
|
|
1ed4c5a171 | ||
|
|
7ea60402e5 | ||
|
|
a202c33707 | ||
|
|
a805922392 | ||
|
|
4df6a4705b | ||
|
|
f43a0f5d9d | ||
|
|
bffef9cb05 | ||
|
|
3f7f2a67c4 | ||
|
|
eb0f35a7a5 | ||
|
|
f09e43efb2 | ||
|
|
92a3ae5c7f | ||
|
|
2cf9536cb1 | ||
|
|
9612d955db | ||
|
|
5ca2a39328 | ||
|
|
fb2b224030 | ||
|
|
ee454f3a82 | ||
|
|
4fd0081817 | ||
|
|
68dbd7a185 | ||
|
|
7475f4554c | ||
|
|
8e688ed51e | ||
|
|
cca6b5e2e7 | ||
|
|
4c316ccc3a | ||
|
|
7c7392a533 | ||
|
|
d4fe03cd18 | ||
|
|
e9670d8a12 | ||
|
|
87e808548b | ||
|
|
79fc3a0a8a | ||
|
|
c8b820a08f | ||
|
|
f45d0ad31d |
@@ -11,34 +11,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
- `Other` - for technical stuff.
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
|
||||
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
|
||||
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
|
||||
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
|
||||
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
|
||||
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
|
||||
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
|
||||
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
|
||||
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
|
||||
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
||||
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
||||
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
|
||||
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
|
||||
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
|
||||
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
|
||||
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
|
||||
|
||||
### Changed
|
||||
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
||||
- Keep online chapters cached for a day instead of up to a size limit ([@AntsyLich](https://github.com/AntsyLich))
|
||||
- Apply the reader's grayscale and inverted colors to pages only, not to transition, loading and error pages ([@AntsyLich](https://github.com/AntsyLich))
|
||||
|
||||
### Removed
|
||||
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
||||
|
||||
### Fixed
|
||||
- Fix extension settings showing duplicate entries after the app is restored from the background ([@AntsyLich](https://github.com/AntsyLich))
|
||||
|
||||
## [v0.21.3] - 2026-10-26
|
||||
### Added
|
||||
|
||||
@@ -273,6 +273,7 @@ dependencies {
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// Disk
|
||||
implementation(libs.diskLruCache)
|
||||
implementation(libs.unifile)
|
||||
|
||||
// Preferences
|
||||
@@ -291,8 +292,6 @@ dependencies {
|
||||
}
|
||||
implementation(libs.image.decoder)
|
||||
|
||||
implementation(libs.webgpuviewer)
|
||||
|
||||
// UI libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.photoView)
|
||||
|
||||
@@ -59,6 +59,30 @@ inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
|
||||
return fastFilter { !predicate(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the original collection into pair of lists,
|
||||
* where *first* list contains elements for which [predicate] yielded `true`,
|
||||
* while *second* list contains elements for which [predicate] yielded `false`.
|
||||
*
|
||||
* **Do not use for collections that come from public APIs**, since they may not support random
|
||||
* access in an efficient way, and this method may actually be a lot slower. Only use for
|
||||
* collections that are created by code we control and are known to support random access.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, List<T>> {
|
||||
contract { callsInPlace(predicate) }
|
||||
val first = ArrayList<T>()
|
||||
val second = ArrayList<T>()
|
||||
fastForEach {
|
||||
if (predicate(it)) {
|
||||
first.add(it)
|
||||
} else {
|
||||
second.add(it)
|
||||
}
|
||||
}
|
||||
return Pair(first, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of entries not matching the given [predicate].
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -37,7 +38,12 @@ class BasePreferences(
|
||||
PRIVATE(MR.strings.ext_installer_private, false),
|
||||
}
|
||||
|
||||
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
|
||||
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
|
||||
|
||||
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
|
||||
"pref_hardware_bitmap_threshold",
|
||||
GLUtil.SAFE_TEXTURE_LIMIT,
|
||||
)
|
||||
|
||||
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
|
||||
|
||||
|
||||
@@ -13,3 +13,13 @@ fun DotSeparatorText(
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DotSeparatorNoSpaceText(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.updatePadding
|
||||
import ca.mpreg.webgpuviewer.renderer.GainmapInput
|
||||
import ca.mpreg.webgpuviewer.renderer.Image
|
||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewer
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewerState
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.size.Size
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.manga.EditCoverAction
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import eu.kanade.tachiyomi.data.coil.newDecoder
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.Close
|
||||
import mihon.icons.materialsymbols.rounded.Edit
|
||||
@@ -79,9 +65,6 @@ fun MangaCoverDialog(
|
||||
onEditClick: ((EditCoverAction) -> Unit)?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get()
|
||||
val view = LocalView.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(
|
||||
@@ -168,59 +151,6 @@ fun MangaCoverDialog(
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (useNewRenderer) {
|
||||
val state = remember(manga) { ImageViewerState() }
|
||||
LaunchedEffect(manga) {
|
||||
val request = ImageRequest.Builder(view.context)
|
||||
.data(manga)
|
||||
.size(Size.ORIGINAL)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.newDecoder(true)
|
||||
.build()
|
||||
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
|
||||
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
|
||||
// Held, then freed, around the upload: its buffer alone doesn't keep
|
||||
// the frame's native pixels alive.
|
||||
val page = res.frame.use {
|
||||
withContext(Dispatchers.Default) {
|
||||
ImagePage.ImageSingle(
|
||||
Image(
|
||||
res.image,
|
||||
res.width,
|
||||
res.height,
|
||||
createMipMaps = true,
|
||||
backgroundColor = 0,
|
||||
hdr = res.isHdr,
|
||||
hdrHeadroom = res.hdrHeadroom,
|
||||
gainmap = res.gainmap?.let {
|
||||
GainmapInput(
|
||||
pixels = it.pixels,
|
||||
width = it.width,
|
||||
height = it.height,
|
||||
channels = it.channels,
|
||||
gamma = it.gamma,
|
||||
minContentBoost = it.minContentBoost,
|
||||
maxContentBoost = it.maxContentBoost,
|
||||
offsetSdr = it.offsetSdr,
|
||||
offsetHdr = it.offsetHdr,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.apply {
|
||||
fetchPage = { index ->
|
||||
if (index == 0) page else null
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
ImageViewer(state = state)
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
|
||||
|
||||
|
||||
@@ -144,6 +144,20 @@ internal class PermissionStep : OnboardingStep {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.secondaryItemAlpha(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionCheckbox(
|
||||
title: String,
|
||||
|
||||
+38
-3
@@ -6,6 +6,8 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
|
||||
import eu.kanade.tachiyomi.ui.more.OnboardingScreen
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
||||
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
|
||||
import eu.kanade.tachiyomi.util.system.powerManager
|
||||
@@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.app.di.appGraph
|
||||
import okhttp3.Headers
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -342,12 +346,43 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
basePreferences: BasePreferences,
|
||||
): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
val chooseColorProfile = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
basePreferences.displayProfile.set(uri.toString())
|
||||
}
|
||||
}
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_category_reader),
|
||||
preferenceItems = listOf(
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
preference = basePreferences.highQualityRenderer,
|
||||
title = stringResource(MR.strings.pref_high_quality_renderer),
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
preference = basePreferences.hardwareBitmapThreshold,
|
||||
entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
|
||||
.mapIndexed { index, option ->
|
||||
val display = if (index == 0) {
|
||||
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
|
||||
} else {
|
||||
option.toString()
|
||||
}
|
||||
option to display
|
||||
}
|
||||
.toMap(),
|
||||
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
|
||||
subtitleProvider = { value, options ->
|
||||
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
|
||||
},
|
||||
visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
|
||||
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_display_profile),
|
||||
subtitle = basePreferences.displayProfile.get(),
|
||||
onClick = {
|
||||
chooseColorProfile.launch(arrayOf("*/*"))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.system.workManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
@@ -286,7 +287,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
val chapterCache = remember { context.appGraph.chapterCache }
|
||||
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
|
||||
val cacheReadableSize by produceState("", cacheReadableSizeSema) {
|
||||
value = chapterCache.readableSize()
|
||||
value = withContext(Dispatchers.IO) { chapterCache.readableSize }
|
||||
}
|
||||
|
||||
return Preference.PreferenceGroup(
|
||||
@@ -310,7 +311,7 @@ object SettingsDataScreen : SearchableSettings {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val deletedFiles = chapterCache.clear()
|
||||
val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
|
||||
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
|
||||
cacheReadableSizeSema++
|
||||
} catch (e: Exception) {
|
||||
|
||||
+3
-10
@@ -25,7 +25,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -43,32 +42,27 @@ internal fun BasePreferenceWidget(
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val contentAlpha = if (enabled) 1f else DisabledAlpha
|
||||
val highlighted = LocalPreferenceHighlighted.current
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.highlightBackground(highlighted)
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable(enabled = enabled && onClick != null, onClick = { onClick?.invoke() })
|
||||
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = PrefsHorizontalPadding, end = 8.dp)
|
||||
.alpha(contentAlpha),
|
||||
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
|
||||
content = { icon() },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = PrefsVerticalPadding)
|
||||
.alpha(contentAlpha),
|
||||
.padding(vertical = PrefsVerticalPadding),
|
||||
) {
|
||||
if (!title.isNullOrBlank()) {
|
||||
Text(
|
||||
@@ -129,4 +123,3 @@ internal val TrailingWidgetBuffer = 16.dp
|
||||
internal val PrefsHorizontalPadding = 16.dp
|
||||
internal val PrefsVerticalPadding = 16.dp
|
||||
internal val TitleFontSize = 16.sp
|
||||
internal val DisabledAlpha = 0.38f
|
||||
|
||||
-35
@@ -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) },
|
||||
)
|
||||
}
|
||||
+14
-41
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.presentation.more.settings.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -47,32 +46,11 @@ fun <T> ListPreferenceWidget(
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
ListPreferenceDialog(
|
||||
title = title,
|
||||
value = value,
|
||||
entries = entries,
|
||||
onValueChange = onValueChange,
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ListPreferenceDialog(
|
||||
title: String?,
|
||||
value: T,
|
||||
entries: Map<out T, String>,
|
||||
onValueChange: (T) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
message: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = title?.let { { Text(text = it) } },
|
||||
text = {
|
||||
Column {
|
||||
if (message != null) PreferenceDialogMessage(message)
|
||||
Box(modifier = Modifier.weight(1f, fill = false)) {
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Box {
|
||||
val state = rememberLazyListState()
|
||||
ScrollbarLazyColumn(state = state) {
|
||||
entries.forEach { current ->
|
||||
@@ -82,8 +60,8 @@ fun <T> ListPreferenceDialog(
|
||||
label = current.value,
|
||||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
onValueChange(current.key)
|
||||
onDismissRequest()
|
||||
onValueChange(current.key!!)
|
||||
isDialogShown = false
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -92,19 +70,14 @@ fun <T> ListPreferenceDialog(
|
||||
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PreferenceDialogMessage(message: String) {
|
||||
Text(text = message, modifier = Modifier.padding(bottom = 8.dp))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+30
-54
@@ -1,6 +1,5 @@
|
||||
package eu.kanade.presentation.more.settings.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
@@ -11,7 +10,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -37,37 +35,16 @@ fun <T> MultiSelectListPreferenceWidget(
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
MultiSelectListPreferenceDialog(
|
||||
title = title,
|
||||
values = values,
|
||||
entries = entries,
|
||||
onValuesChange = onValuesChange,
|
||||
val selected = remember {
|
||||
entries.keys
|
||||
.filter { values.contains(it) }
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> MultiSelectListPreferenceDialog(
|
||||
title: String?,
|
||||
values: Set<T>,
|
||||
entries: Map<out T, String>,
|
||||
onValuesChange: (Set<T>) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
message: String? = null,
|
||||
) {
|
||||
val selected = remember {
|
||||
entries.keys
|
||||
.filter { values.contains(it) }
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = title?.let { { Text(text = it) } },
|
||||
text = {
|
||||
Column {
|
||||
if (message != null) PreferenceDialogMessage(message)
|
||||
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
LazyColumn {
|
||||
entries.forEach { current ->
|
||||
item {
|
||||
val isSelected = selected.contains(current.key)
|
||||
@@ -85,26 +62,25 @@ fun <T> MultiSelectListPreferenceDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Values without an entry stay, as they can't be unselected here
|
||||
onValuesChange(values.filterNot { it in entries.keys }.toMutableSet().apply { addAll(selected) })
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onValuesChange(selected.toMutableSet())
|
||||
isDialogShown = false
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -19,7 +19,6 @@ fun SwitchPreferenceWidget(
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
checked: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
TextPreferenceWidget(
|
||||
@@ -31,11 +30,9 @@ fun SwitchPreferenceWidget(
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.padding(start = TrailingWidgetBuffer),
|
||||
)
|
||||
},
|
||||
enabled = enabled,
|
||||
onPreferenceClick = { onCheckedChanged(!checked) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ fun TextPreferenceWidget(
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
onPreferenceClick: (() -> Unit)? = null,
|
||||
) {
|
||||
BasePreferenceWidget(
|
||||
@@ -57,7 +56,6 @@ fun TextPreferenceWidget(
|
||||
},
|
||||
onClick = onPreferenceClick,
|
||||
widget = widget,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ fun OrientationSelectDialog(
|
||||
DialogContent(
|
||||
orientation = orientation,
|
||||
onChangeOrientation = {
|
||||
viewModel.setOrientation(it)
|
||||
viewModel.onChangeOrientation(it)
|
||||
onChange(it.stringRes)
|
||||
onDismissRequest()
|
||||
},
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
/**
|
||||
* A page that failed to load or decode: the message, Retry, and Open in WebView when [onOpenInWebView] is given.
|
||||
*
|
||||
* [onButtonPressChange] tells a viewer that reads every touch for its own taps when a button is held.
|
||||
*/
|
||||
@Composable
|
||||
fun ReaderError(
|
||||
message: String?,
|
||||
onRetry: () -> Unit,
|
||||
onOpenInWebView: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val buttonModifier = if (onButtonPressChange != null) Modifier.reportPress(onButtonPressChange) else Modifier
|
||||
Column(
|
||||
modifier = modifier.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Text(
|
||||
text = message ?: stringResource(MR.strings.decode_image_error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(onClick = onRetry, modifier = buttonModifier) {
|
||||
Text(stringResource(MR.strings.action_retry))
|
||||
}
|
||||
if (onOpenInWebView != null) {
|
||||
Button(onClick = onOpenInWebView, modifier = buttonModifier) {
|
||||
Text(stringResource(MR.strings.action_open_in_web_view))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Modifier.reportPress(onPressChange: (Boolean) -> Unit) = pointerInput(onPressChange) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
onPressChange(true)
|
||||
waitForUpOrCancellation(pass = PointerEventPass.Initial)
|
||||
onPressChange(false)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package eu.kanade.presentation.reader
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun ReaderNavigationOverlay(
|
||||
state: ReaderNavigationOverlayState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.visible,
|
||||
modifier = modifier,
|
||||
enter = fadeIn(tween(FADE_DURATION)),
|
||||
exit = fadeOut(tween(FADE_DURATION)),
|
||||
) {
|
||||
val regions = state.regions
|
||||
val labels = regions.map { stringResource(it.type.nameRes) }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val textStyle = remember(density) {
|
||||
TextStyle(color = Color.White, fontSize = with(density) { 64f.toSp() })
|
||||
}
|
||||
val borderStyle = remember(textStyle) {
|
||||
textStyle.copy(color = Color.Black, drawStyle = Stroke(width = 8f))
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
regions.forEachIndexed { index, region ->
|
||||
val rect = region.rect
|
||||
val topLeft = Offset(size.width * rect.left, size.height * rect.top)
|
||||
val regionSize = Size(size.width * rect.width, size.height * rect.height)
|
||||
drawRect(color = region.type.color, topLeft = topLeft, size = regionSize)
|
||||
|
||||
val text = textMeasurer.measure(labels[index], textStyle)
|
||||
val textTopLeft = Offset(
|
||||
x = topLeft.x + (regionSize.width - text.size.width) / 2,
|
||||
y = topLeft.y + (regionSize.height - text.size.height) / 2,
|
||||
)
|
||||
drawText(textMeasurer, labels[index], textTopLeft, borderStyle)
|
||||
drawText(text, topLeft = textTopLeft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class ReaderNavigationOverlayState {
|
||||
|
||||
internal var regions by mutableStateOf(emptyList<ViewerNavigation.Region>())
|
||||
internal var visible by mutableStateOf(false)
|
||||
|
||||
private var navigation: ViewerNavigation? = null
|
||||
|
||||
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
|
||||
val firstLaunch = this.navigation == null
|
||||
this.navigation = navigation
|
||||
regions = navigation.getRegions()
|
||||
|
||||
if (visible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
|
||||
return
|
||||
}
|
||||
visible = true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
private const val FADE_DURATION = 1000
|
||||
@@ -39,7 +39,7 @@ fun ReadingModeSelectDialog(
|
||||
DialogContent(
|
||||
readingMode = readingMode,
|
||||
onChangeReadingMode = {
|
||||
viewModel.setReadingMode(it)
|
||||
viewModel.onChangeReadingMode(it)
|
||||
onChange(it.stringRes)
|
||||
onDismissRequest()
|
||||
},
|
||||
|
||||
@@ -8,13 +8,15 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.domain.manga.model.readerOrientation
|
||||
import eu.kanade.domain.manga.model.readingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import mihon.app.di.appGraph
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.CheckboxItem
|
||||
import tachiyomi.presentation.core.components.HeadingItem
|
||||
@@ -26,20 +28,15 @@ import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||
val isWebGpu by viewModel.isWebGpu.collectAsState()
|
||||
if (isWebGpu) {
|
||||
WebGpuViewerSettings(viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
HeadingItem(MR.strings.pref_category_for_this_series)
|
||||
val manga by viewModel.mangaFlow.collectAsState()
|
||||
|
||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||
SettingsChipRow(MR.strings.pref_category_reading_mode) {
|
||||
ReadingMode.entries.map {
|
||||
FilterChip(
|
||||
selected = it == readingMode,
|
||||
onClick = { viewModel.setReadingMode(it) },
|
||||
onClick = { viewModel.onChangeReadingMode(it) },
|
||||
label = { Text(stringResource(it.stringRes)) },
|
||||
)
|
||||
}
|
||||
@@ -50,14 +47,14 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||
ReaderOrientation.entries.map {
|
||||
FilterChip(
|
||||
selected = it == orientation,
|
||||
onClick = { viewModel.setOrientation(it) },
|
||||
onClick = { viewModel.onChangeOrientation(it) },
|
||||
label = { Text(stringResource(it.stringRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedReadingMode by viewModel.readingMode.collectAsState()
|
||||
if (resolvedReadingMode.type is ReadingMode.ViewerType.Webtoon) {
|
||||
val viewer by viewModel.viewerFlow.collectAsState()
|
||||
if (viewer is WebtoonViewer) {
|
||||
WebtoonViewerSettings(viewModel)
|
||||
} else {
|
||||
PagerViewerSettings(viewModel)
|
||||
@@ -210,7 +207,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TapZonesItems(
|
||||
private fun ColumnScope.TapZonesItems(
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
invertMode: ReaderPreferences.TappingInvertMode,
|
||||
|
||||
@@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.system.cancelNotification
|
||||
@@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
||||
.onEach(TelemetryConfig::setCrashlyticsEnabled)
|
||||
.launchIn(scope)
|
||||
|
||||
basePreferences.hardwareBitmapThreshold.let { preference ->
|
||||
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
|
||||
}
|
||||
|
||||
basePreferences.hardwareBitmapThreshold.changes()
|
||||
.onEach { ImageUtil.hardwareBitmapThreshold = it }
|
||||
.launchIn(scope)
|
||||
|
||||
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
|
||||
|
||||
// Updates widget update
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package eu.kanade.tachiyomi.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import com.jakewharton.disklrucache.DiskLruCache
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import okhttp3.Response
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Class used to create chapter cache
|
||||
* For each image in a chapter a file is created
|
||||
* For each chapter a Json list is created and converted to a file.
|
||||
* The files are in format *md5key*.0
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
class ChapterCache(
|
||||
private val context: Context,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
/** Cache class used for cache management. */
|
||||
private val diskCache by lazy {
|
||||
DiskLruCache.open(
|
||||
File(context.cacheDir, "chapter_disk_cache"),
|
||||
PARAMETER_APP_VERSION,
|
||||
PARAMETER_VALUE_COUNT,
|
||||
PARAMETER_CACHE_SIZE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns directory of cache.
|
||||
*/
|
||||
private val cacheDir: File
|
||||
get() = diskCache.directory
|
||||
|
||||
/**
|
||||
* Returns real size of directory.
|
||||
*/
|
||||
private val realSize: Long
|
||||
get() = DiskUtil.getDirectorySize(cacheDir)
|
||||
|
||||
/**
|
||||
* Returns real size of directory in human readable format.
|
||||
*/
|
||||
val readableSize: String
|
||||
get() = Formatter.formatFileSize(context, realSize)
|
||||
|
||||
/**
|
||||
* Get page list from cache.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
* @return the list of pages.
|
||||
*/
|
||||
fun getPageListFromCache(chapter: Chapter): List<Page> {
|
||||
// Get the key for the chapter.
|
||||
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||
|
||||
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
||||
return diskCache.get(key).use {
|
||||
json.decodeFromString(it.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add page list to disk cache.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
* @param pages list of pages.
|
||||
*/
|
||||
fun putPageListToCache(chapter: Chapter, pages: List<Page>) {
|
||||
// Convert list of pages to json string.
|
||||
val cachedValue = json.encodeToString(pages)
|
||||
|
||||
// Initialize the editor (edits the values for an entry).
|
||||
var editor: DiskLruCache.Editor? = null
|
||||
|
||||
try {
|
||||
// Get editor from md5 key.
|
||||
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||
editor = diskCache.edit(key) ?: return
|
||||
|
||||
// Write chapter urls to cache.
|
||||
editor.newOutputStream(0).sink().buffer().use {
|
||||
it.write(cachedValue.toByteArray())
|
||||
it.flush()
|
||||
}
|
||||
|
||||
diskCache.flush()
|
||||
editor.commit()
|
||||
editor.abortUnlessCommitted()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
|
||||
// Ignore.
|
||||
} finally {
|
||||
editor?.abortUnlessCommitted()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if image is in cache.
|
||||
*
|
||||
* @param imageUrl url of image.
|
||||
* @return true if in cache otherwise false.
|
||||
*/
|
||||
fun isImageInCache(imageUrl: String): Boolean {
|
||||
return try {
|
||||
val key = DiskUtil.hashKeyForDisk(imageUrl)
|
||||
val inJournal = diskCache.get(key).use { it != null }
|
||||
val fileExists = getImageFile(imageUrl).exists()
|
||||
if (inJournal && !fileExists) {
|
||||
logcat(LogPriority.WARN) { "Image is in journal but file is missing: $imageUrl" }
|
||||
}
|
||||
inJournal && fileExists
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image file from url.
|
||||
*
|
||||
* @param imageUrl url of image.
|
||||
* @return path of image.
|
||||
*/
|
||||
fun getImageFile(imageUrl: String): File {
|
||||
// Get file from md5 key.
|
||||
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
|
||||
return File(diskCache.directory, imageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image to cache.
|
||||
*
|
||||
* @param imageUrl url of image.
|
||||
* @param response http response from page.
|
||||
* @throws IOException image error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun putImageToCache(imageUrl: String, response: Response) {
|
||||
// Initialize editor (edits the values for an entry).
|
||||
var editor: DiskLruCache.Editor? = null
|
||||
|
||||
try {
|
||||
// Get editor from md5 key.
|
||||
val key = DiskUtil.hashKeyForDisk(imageUrl)
|
||||
editor = diskCache.edit(key) ?: return
|
||||
|
||||
// Get OutputStream and write image with Okio.
|
||||
response.body.source().saveTo(editor.newOutputStream(0))
|
||||
|
||||
diskCache.flush()
|
||||
editor.commit()
|
||||
} finally {
|
||||
response.body.close()
|
||||
editor?.abortUnlessCommitted()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(): Int {
|
||||
var deletedFiles = 0
|
||||
cacheDir.listFiles()?.forEach {
|
||||
if (removeFileFromCache(it.name)) {
|
||||
deletedFiles++
|
||||
}
|
||||
}
|
||||
return deletedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove file from cache.
|
||||
*
|
||||
* @param file name of file "md5.0".
|
||||
* @return status of deletion for the file.
|
||||
*/
|
||||
private fun removeFileFromCache(file: String): Boolean {
|
||||
// Make sure we don't delete the journal file (keeps track of cache)
|
||||
if (file == "journal" || file.startsWith("journal.")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
// Remove the extension from the file to get the key of the cache
|
||||
val key = file.substringBeforeLast(".")
|
||||
// Remove file from cache
|
||||
diskCache.remove(key)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKey(chapter: Chapter): String {
|
||||
return "${chapter.mangaId}${chapter.url}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Application cache version. */
|
||||
private const val PARAMETER_APP_VERSION = 1
|
||||
|
||||
/** The number of values per cache entry. Must be positive. */
|
||||
private const val PARAMETER_VALUE_COUNT = 1
|
||||
|
||||
/** The maximum number of bytes this cache should use to store. */
|
||||
private const val PARAMETER_CACHE_SIZE = 100L * 1024 * 1024
|
||||
@@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import androidx.core.graphics.createBitmap
|
||||
import ca.mpreg.imagedecoder.ImageDecoder
|
||||
import coil3.Canvas
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
@@ -14,39 +12,20 @@ import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.bitmapConfig
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
|
||||
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
|
||||
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
|
||||
*/
|
||||
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
|
||||
|
||||
/**
|
||||
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
|
||||
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
|
||||
*/
|
||||
class DecodeResultImage(
|
||||
val frame: ImageDecoder.Frame,
|
||||
val isHdr: Boolean,
|
||||
val hdrHeadroom: Float,
|
||||
val gainmap: ImageDecoder.Gainmap?,
|
||||
) : Image {
|
||||
val image: java.nio.ByteBuffer get() = frame.image
|
||||
|
||||
// Taken now: a caller may close the frame once it has the pixels.
|
||||
override val size: Long = frame.image.capacity().toLong()
|
||||
override val width: Int get() = frame.width
|
||||
override val height: Int get() = frame.height
|
||||
override val shareable: Boolean get() = true
|
||||
override fun draw(canvas: Canvas) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
|
||||
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
|
||||
@@ -63,105 +42,102 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
override suspend fun decode(): DecodeResult {
|
||||
if (options.webtoonStrips) return decodeStrips()
|
||||
|
||||
return resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec ->
|
||||
// Raw frame for custom rendering; sampling is the caller's.
|
||||
if (options.newDecoder) {
|
||||
return@use DecodeResult(
|
||||
image = DecodeResultImage(
|
||||
dec.decodeNext(),
|
||||
dec.isHdr,
|
||||
dec.hdrHeadroom,
|
||||
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
|
||||
),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||
}
|
||||
|
||||
// Shrunk while decoding. Sizes round up, so may be a pixel over BitmapFactory's.
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = dec.width,
|
||||
srcHeight = dec.height,
|
||||
dstWidth = options.size.widthPx(options.scale) { dec.width },
|
||||
dstHeight = options.size.heightPx(options.scale) { dec.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
DecodeResult(
|
||||
image = dec.decodeToBitmap(null, sampleSize).asImage(),
|
||||
isSampled = sampleSize > 1,
|
||||
)
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
val srcWidth = decoder.width
|
||||
val srcHeight = decoder.height
|
||||
|
||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
|
||||
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = srcWidth,
|
||||
srcHeight = srcHeight,
|
||||
dstWidth = dstWidth,
|
||||
dstHeight = dstHeight,
|
||||
scale = options.scale,
|
||||
)
|
||||
|
||||
var bitmap = decoder.decode(sampleSize = sampleSize)
|
||||
decoder.recycle()
|
||||
|
||||
check(bitmap != null) { "Failed to decode image" }
|
||||
|
||||
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
|
||||
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
|
||||
if (hwBitmap != null) {
|
||||
bitmap.recycle()
|
||||
bitmap = hwBitmap
|
||||
}
|
||||
}
|
||||
|
||||
return DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = sampleSize > 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies bands of rows straight from the native frame into strips, so the page never exists as
|
||||
* one Java bitmap on top of the frame. The frame is decoded once, at the largest whole fraction
|
||||
* still at least the strip width: per-strip regions would re-decode the rows above each.
|
||||
* Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied.
|
||||
*
|
||||
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
|
||||
* in still shows the page's detail, unless that would make the page take more than
|
||||
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
|
||||
*/
|
||||
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
|
||||
val (frame, isHdr, widths) = resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec ->
|
||||
val bytesPerPixel = if (dec.isHdr) 8 else 4
|
||||
val viewWidth = options.size.widthPx(options.scale) { dec.width }
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = dec.width,
|
||||
srcHeight = dec.height,
|
||||
dstWidth = viewWidth,
|
||||
dstHeight = options.size.heightPx(options.scale) { dec.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * dec.width / dec.height).toInt()
|
||||
val stripWidth = maxOf(viewWidth, min(dec.width / sampleSize, budgetWidth))
|
||||
// Older GPUs can't draw a texture wider than this
|
||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||
.coerceIn(1, dec.width)
|
||||
// ceil(width / decodeSample) >= stripWidth: strips only scale down
|
||||
val decodeSample = (dec.width / stripWidth).coerceAtLeast(1)
|
||||
Triple(dec.decode(null, decodeSample), dec.isHdr, stripWidth to dec.width)
|
||||
}
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||
}
|
||||
val (stripWidth, pageWidth) = widths
|
||||
frame.use {
|
||||
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
||||
val bytesPerPixel = if (isHdr) 8 else 4
|
||||
val rowBytes = frame.width * bytesPerPixel
|
||||
val scale = stripWidth.toFloat() / frame.width
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
|
||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||
val band = createBitmap(frame.width, bandRows, config)
|
||||
val strips = (0..<frame.height step bandRows).map { top ->
|
||||
val rows = min(bandRows, frame.height - top)
|
||||
if (rows != band.height) band.reconfigure(frame.width, rows, config)
|
||||
band.copyPixelsFromBuffer(
|
||||
frame.image.duplicate().apply {
|
||||
position(top * rowBytes)
|
||||
limit((top + rows) * rowBytes)
|
||||
},
|
||||
)
|
||||
val strip = if (stripWidth < frame.width) {
|
||||
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
|
||||
} else {
|
||||
band
|
||||
}
|
||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
|
||||
val srcWidth = decoder.width
|
||||
val viewWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = decoder.width,
|
||||
srcHeight = decoder.height,
|
||||
dstWidth = viewWidth,
|
||||
dstHeight = options.size.heightPx(options.scale) { decoder.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
val page = decoder.decode(sampleSize = sampleSize)
|
||||
decoder.recycle()
|
||||
check(page != null) { "Failed to decode image" }
|
||||
|
||||
val config = page.config ?: Bitmap.Config.ARGB_8888
|
||||
val bytesPerPixel = page.rowBytes / page.width
|
||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt()
|
||||
val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth))
|
||||
// Older GPUs can't draw a texture wider than this
|
||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||
.coerceIn(1, page.width)
|
||||
val scale = stripWidth.toFloat() / page.width
|
||||
|
||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height)
|
||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||
val strips = (0..<page.height step bandRows).map { top ->
|
||||
val rows = min(bandRows, page.height - top)
|
||||
val band = if (stripWidth < page.width) {
|
||||
Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true)
|
||||
} else {
|
||||
Bitmap.createBitmap(page, 0, top, page.width, rows)
|
||||
}
|
||||
band.recycle()
|
||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth)
|
||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||
band.copy(config, false).also { if (band !== page) band.recycle() }
|
||||
}
|
||||
page.recycle()
|
||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth)
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
||||
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
|
||||
return if (options.webtoonStrips || isApplicable(result.source.source())) {
|
||||
ImageDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
@@ -173,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
ImageUtil.findImageType(it)
|
||||
}
|
||||
return when (type) {
|
||||
ImageUtil.ImageType.AVIF,
|
||||
ImageUtil.ImageType.JXL,
|
||||
ImageUtil.ImageType.HEIF,
|
||||
ImageUtil.ImageType.JP2,
|
||||
-> true
|
||||
|
||||
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -187,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var displayProfile: ByteArray? = null
|
||||
}
|
||||
}
|
||||
|
||||
private const val STRIP_BYTES = 4 * 1024 * 1024
|
||||
@@ -195,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private const val MAX_STRIP_WIDTH = 4096
|
||||
|
||||
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
|
||||
// Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel
|
||||
private val stripDecodes = Semaphore(1)
|
||||
|
||||
@@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean
|
||||
get() = getExtra(webtoonStripsKey)
|
||||
|
||||
private val webtoonStripsKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
|
||||
extras[newDecoderKey] = enable
|
||||
}
|
||||
val Options.newDecoder: Boolean
|
||||
get() = getExtra(newDecoderKey)
|
||||
|
||||
private val newDecoderKey = Extras.Key(default = false)
|
||||
|
||||
@@ -7,6 +7,7 @@ import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.domain.chapter.model.toSChapter
|
||||
import eu.kanade.domain.manga.model.getComicInfo
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
@@ -49,7 +50,6 @@ import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.core.archive.ZipWriter
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
import mihon.data.cache.ChapterCache
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
@@ -64,6 +64,7 @@ import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -543,8 +544,10 @@ class Downloader(
|
||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
||||
val file = when {
|
||||
imageFile != null -> imageFile
|
||||
else -> copyImageFromCache(page.imageUrl!!, tmpDir, filename)
|
||||
?: downloadImage(page, download.source, tmpDir, filename)
|
||||
chapterCache.isImageInCache(
|
||||
page.imageUrl!!,
|
||||
) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename)
|
||||
else -> downloadImage(page, download.source, tmpDir, filename)
|
||||
}
|
||||
|
||||
// When the page is ready, set page path, progress (just in case) and status
|
||||
@@ -607,25 +610,24 @@ class Downloader(
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the image from cache to file in tmpDir, or returns null when it isn't cached.
|
||||
* Copies the image from cache to file in tmpDir.
|
||||
*
|
||||
* @param imageUrl the url of the image.
|
||||
* @param cacheFile the file from cache.
|
||||
* @param tmpDir the temporary directory of the download.
|
||||
* @param filename the filename of the image.
|
||||
*/
|
||||
private suspend fun copyImageFromCache(imageUrl: String, tmpDir: UniFile, filename: String): UniFile? {
|
||||
val tmpFile = chapterCache.openImage(imageUrl)?.use { input ->
|
||||
// Delete temp file if it exists
|
||||
tmpDir.findFile("$filename.tmp")?.delete()
|
||||
tmpDir.createFile("$filename.tmp")!!.also { tmpFile ->
|
||||
tmpFile.openOutputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
|
||||
// Delete temp file if it exists
|
||||
tmpDir.findFile("$filename.tmp")?.delete()
|
||||
val tmpFile = tmpDir.createFile("$filename.tmp")!!
|
||||
cacheFile.inputStream().use { input ->
|
||||
tmpFile.openOutputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
chapterCache.removeImage(imageUrl)
|
||||
val extension = tmpFile.openInputStream().use { ImageUtil.findImageType(it) } ?: return tmpFile
|
||||
}
|
||||
val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile
|
||||
tmpFile.renameTo("$filename.${extension.extension}")
|
||||
cacheFile.delete()
|
||||
return tmpFile
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,20 @@ object NotificationHandler {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that prompts user with apk install intent
|
||||
*
|
||||
* @param context context
|
||||
* @param uri uri of apk that is installed
|
||||
*/
|
||||
fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, ExtensionInstaller.APK_MIME)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
fun openUrl(context: Context, url: String): PendingIntent {
|
||||
val notificationIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
return PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
@@ -294,6 +294,26 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which dismissed the notification
|
||||
*
|
||||
* @param context context of application
|
||||
* @param notificationId id of notification
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_DISMISS_NOTIFICATION
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which dismissed the notification
|
||||
*
|
||||
|
||||
+118
-297
@@ -1,73 +1,49 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.extension.details
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.DialogPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceGroup
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.TwoStatePreference
|
||||
import androidx.preference.R
|
||||
import androidx.preference.forEach
|
||||
import androidx.preference.getOnBindEditTextListener
|
||||
import androidx.preference.setOnStateChangeListener
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.more.settings.widget.CheckboxPreferenceWidget
|
||||
import eu.kanade.presentation.more.settings.widget.ListPreferenceDialog
|
||||
import eu.kanade.presentation.more.settings.widget.MultiSelectListPreferenceDialog
|
||||
import eu.kanade.presentation.more.settings.widget.PreferenceDialogMessage
|
||||
import eu.kanade.presentation.more.settings.widget.PreferenceGroupHeader
|
||||
import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
|
||||
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||
import eu.kanade.tachiyomi.util.view.setIncognito
|
||||
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.app.di.appGraph
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
|
||||
class SourcePreferencesScreen(val sourceId: Long) : Screen() {
|
||||
@@ -77,290 +53,135 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
// The preferences are made anew after a configuration change, so an open dialog is restored by key
|
||||
var savedDialogKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val screen by produceState<ExtensionPreferenceScreen?>(initialValue = null) {
|
||||
val source = context.appGraph.sourceManager.getOrStub(sourceId)
|
||||
// Reading a value loads the file, which shouldn't happen on the main thread
|
||||
val sourcePreferences = (source as? ConfigurableSource)?.let {
|
||||
withContext(Dispatchers.IO) { it.sourcePreferences().apply { all } }
|
||||
}
|
||||
value = ExtensionPreferenceScreen(context, source, sourcePreferences, savedDialogKey)
|
||||
val source by produceState<Source?>(initialValue = null) {
|
||||
value = context.appGraph.sourceManager.getOrStub(sourceId)
|
||||
}
|
||||
|
||||
val preferenceScreen = screen
|
||||
if (preferenceScreen == null) {
|
||||
if (source == null) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
DisposableEffect(preferenceScreen) {
|
||||
onDispose { preferenceScreen.dispose() }
|
||||
}
|
||||
SideEffect { savedDialogKey = preferenceScreen.dialogPreference?.key }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = preferenceScreen.title,
|
||||
title = source.toString(),
|
||||
navigateUp = navigator::pop,
|
||||
scrollBehavior = it,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
val items = remember(preferenceScreen.version) { preferenceScreen.items() }
|
||||
ScrollbarLazyColumn(contentPadding = contentPadding) {
|
||||
items(items) { PreferenceRow(it) }
|
||||
FragmentContainer(
|
||||
fragmentManager = (context as FragmentActivity).supportFragmentManager,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
add(it, SourcePreferencesFragment.getInstance(sourceId), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceScreen.dialogPreference?.let {
|
||||
PreferenceDialog(
|
||||
preference = it,
|
||||
onDismissRequest = { preferenceScreen.dialogPreference = null },
|
||||
)
|
||||
/**
|
||||
* From https://stackoverflow.com/questions/60520145/fragment-container-in-jetpack-compose/70817794#70817794
|
||||
*/
|
||||
@Composable
|
||||
private fun FragmentContainer(
|
||||
fragmentManager: FragmentManager,
|
||||
modifier: Modifier = Modifier,
|
||||
commit: FragmentTransaction.(containerId: Int) -> Unit,
|
||||
) {
|
||||
val containerId by rememberSaveable {
|
||||
mutableIntStateOf(View.generateViewId())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen an extension fills in [ConfigurableSource.setupPreferenceScreen]. The [androidx.preference] objects stay
|
||||
* the model, so extensions keep the exact behaviour of the library, and are only drawn with Compose.
|
||||
*/
|
||||
@Stable
|
||||
@SuppressLint("RestrictedApi")
|
||||
private class ExtensionPreferenceScreen(
|
||||
context: Context,
|
||||
source: Source,
|
||||
sourcePreferences: SharedPreferences?,
|
||||
dialogKey: String?,
|
||||
) {
|
||||
val title = source.toString()
|
||||
|
||||
// Bumped on every change the library would rebind a preference's view for
|
||||
var version by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
var dialogPreference by mutableStateOf<DialogPreference?>(null)
|
||||
|
||||
private val screen: PreferenceScreen
|
||||
|
||||
init {
|
||||
val preferenceManager = PreferenceManager(context)
|
||||
if (sourcePreferences != null) {
|
||||
preferenceManager.preferenceDataStore = SharedPreferencesDataStore(sourcePreferences)
|
||||
}
|
||||
preferenceManager.setOnDisplayPreferenceDialogListener { dialogPreference = it as? DialogPreference }
|
||||
|
||||
screen = preferenceManager.createPreferenceScreen(context)
|
||||
(source as? ConfigurableSource)?.setupPreferenceScreen(screen)
|
||||
preferenceManager.setPreferences(screen)
|
||||
screen.onAttached()
|
||||
observe(screen)
|
||||
dialogPreference = dialogKey?.let { screen.findPreference<Preference>(it) as? DialogPreference }
|
||||
}
|
||||
|
||||
private fun observe(preference: Preference) {
|
||||
preference.setOnStateChangeListener {
|
||||
// A group changing may mean preferences were added to it
|
||||
if (it is PreferenceGroup) observe(it)
|
||||
version++
|
||||
}
|
||||
if (preference is PreferenceGroup) {
|
||||
repeat(preference.preferenceCount) { observe(preference.getPreference(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun items(): List<PreferenceItem> = buildList { addVisible(screen) }
|
||||
|
||||
private fun MutableList<PreferenceItem>.addVisible(group: PreferenceGroup) {
|
||||
repeat(group.preferenceCount) {
|
||||
val preference = group.getPreference(it)
|
||||
if (!preference.isVisible) return@repeat
|
||||
add(PreferenceItem.of(preference))
|
||||
if (preference is PreferenceGroup) addVisible(preference)
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
screen.onDetached()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a preference shows, read once per change so the list redraws when an extension changes a preference.
|
||||
*/
|
||||
private data class PreferenceItem(
|
||||
val preference: Preference,
|
||||
val title: String?,
|
||||
val summary: String?,
|
||||
val enabled: Boolean,
|
||||
val checked: Boolean?,
|
||||
) {
|
||||
companion object {
|
||||
fun of(preference: Preference): PreferenceItem {
|
||||
val summary = when {
|
||||
preference !is TwoStatePreference -> preference.summary
|
||||
preference.isChecked && !preference.summaryOn.isNullOrEmpty() -> preference.summaryOn
|
||||
!preference.isChecked && !preference.summaryOff.isNullOrEmpty() -> preference.summaryOff
|
||||
else -> preference.summary
|
||||
}
|
||||
return PreferenceItem(
|
||||
preference = preference,
|
||||
title = preference.title?.toString(),
|
||||
summary = summary?.toString(),
|
||||
enabled = preference.isEnabled,
|
||||
checked = (preference as? TwoStatePreference)?.isChecked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Composable
|
||||
private fun PreferenceRow(item: PreferenceItem) {
|
||||
val preference = item.preference
|
||||
val onClick = if (preference.isSelectable) preference::performClick else null
|
||||
when {
|
||||
preference is PreferenceGroup -> PreferenceGroupHeader(title = item.title.orEmpty())
|
||||
preference is CheckBoxPreference -> CheckboxPreferenceWidget(
|
||||
title = item.title.orEmpty(),
|
||||
subtitle = item.summary,
|
||||
checked = item.checked == true,
|
||||
enabled = item.enabled,
|
||||
onCheckedChanged = { onClick?.invoke() },
|
||||
)
|
||||
item.checked != null -> SwitchPreferenceWidget(
|
||||
title = item.title.orEmpty(),
|
||||
subtitle = item.summary,
|
||||
checked = item.checked,
|
||||
enabled = item.enabled,
|
||||
onCheckedChanged = { onClick?.invoke() },
|
||||
)
|
||||
else -> TextPreferenceWidget(
|
||||
title = item.title,
|
||||
subtitle = item.summary,
|
||||
enabled = item.enabled,
|
||||
onPreferenceClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferenceDialog(preference: DialogPreference, onDismissRequest: () -> Unit) {
|
||||
val title = (preference.dialogTitle?.takeIf { it.isNotEmpty() } ?: preference.title)?.toString()
|
||||
val message = preference.dialogMessage?.toString()
|
||||
when (preference) {
|
||||
is ListPreference -> ListPreferenceDialog(
|
||||
title = title,
|
||||
message = message,
|
||||
value = preference.value,
|
||||
entries = entryMap(preference.entries, preference.entryValues),
|
||||
onValueChange = {
|
||||
if (preference.callChangeListener(it)) preference.value = it
|
||||
var initialized by rememberSaveable { mutableStateOf(false) }
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
FragmentContainerView(context)
|
||||
.apply { id = containerId }
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
is MultiSelectListPreference -> MultiSelectListPreferenceDialog(
|
||||
title = title,
|
||||
message = message,
|
||||
values = preference.values,
|
||||
entries = entryMap(preference.entries, preference.entryValues),
|
||||
onValuesChange = {
|
||||
val values = it.toHashSet()
|
||||
if (values != preference.values && preference.callChangeListener(values)) {
|
||||
preference.values = values
|
||||
update = { view ->
|
||||
if (!initialized) {
|
||||
fragmentManager.commit { commit(view.id) }
|
||||
initialized = true
|
||||
} else {
|
||||
fragmentManager.onContainerAvailable(view)
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
is EditTextPreference -> EditTextPreferenceDialog(preference, title, message, onDismissRequest)
|
||||
else -> LaunchedEffect(preference) { onDismissRequest() }
|
||||
}
|
||||
|
||||
/** Access to package-private method in FragmentManager through reflection */
|
||||
private fun FragmentManager.onContainerAvailable(view: FragmentContainerView) {
|
||||
val method = FragmentManager::class.java.getDeclaredMethod(
|
||||
"onContainerAvailable",
|
||||
FragmentContainerView::class.java,
|
||||
)
|
||||
method.isAccessible = true
|
||||
method.invoke(this, view)
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryMap(entries: Array<CharSequence>?, entryValues: Array<CharSequence>?): Map<String, String> {
|
||||
return entryValues.orEmpty().zip(entries.orEmpty()) { value, entry -> value.toString() to entry.toString() }.toMap()
|
||||
}
|
||||
class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
/**
|
||||
* Extensions get a real [EditText] through [EditTextPreference.OnBindEditTextListener], and some validate input by
|
||||
* disabling the dialog's `android.R.id.button1` found from [EditText.getRootView], so one stands in for the OK button.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditTextPreferenceDialog(
|
||||
preference: EditTextPreference,
|
||||
title: String?,
|
||||
message: String?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// What's typed, kept across a configuration change
|
||||
var draft by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var editText by remember { mutableStateOf<EditText?>(null) }
|
||||
var confirmEnabled by remember { mutableStateOf(true) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = title?.let { { Text(text = it) } },
|
||||
text = {
|
||||
// Like the library's dialog, which shows the keyboard as it opens
|
||||
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
SideEffect { window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) }
|
||||
Column {
|
||||
if (message != null) PreferenceDialogMessage(message)
|
||||
AndroidView(
|
||||
factory = { viewContext ->
|
||||
LinearLayout(viewContext).apply {
|
||||
val confirmButton = object : Button(viewContext) {
|
||||
init {
|
||||
id = android.R.id.button1
|
||||
visibility = GONE
|
||||
}
|
||||
override fun getContext(): Context? {
|
||||
val superCtx = super.getContext() ?: return null
|
||||
val tv = TypedValue()
|
||||
superCtx.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
|
||||
return ContextThemeWrapper(superCtx, tv.resourceId)
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
confirmEnabled = enabled
|
||||
}
|
||||
}
|
||||
addView(confirmButton)
|
||||
val field = EditText(viewContext).apply {
|
||||
// In the order the library's own dialog binds it
|
||||
setText(draft ?: preference.text)
|
||||
setSelection(text.length)
|
||||
setIncognito(context.appGraph.basePreferences, scope)
|
||||
preference.getOnBindEditTextListener()?.onBindEditText(this)
|
||||
doAfterTextChanged { draft = it?.toString() }
|
||||
// Focus only takes once attached to the dialog's window
|
||||
post { requestFocus() }
|
||||
}
|
||||
addView(field, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText = field
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = confirmEnabled,
|
||||
onClick = {
|
||||
val value = editText?.text?.toString()
|
||||
if (value != null && preference.callChangeListener(value)) {
|
||||
preference.text = value
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
||||
lifecycleScope.launch {
|
||||
preferenceScreen = populateScreen()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun populateScreen(): PreferenceScreen {
|
||||
val sourceId = requireArguments().getLong(SOURCE_ID)
|
||||
val appGraph = requireContext().appGraph
|
||||
val source = appGraph.sourceManager.getOrStub(sourceId)
|
||||
val basePreferences = appGraph.basePreferences
|
||||
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
||||
|
||||
if (source is ConfigurableSource) {
|
||||
val sourcePreferences = withContext(Dispatchers.IO) { source.sourcePreferences().also { it.all } }
|
||||
val dataStore = SharedPreferencesDataStore(sourcePreferences)
|
||||
preferenceManager.preferenceDataStore = dataStore
|
||||
|
||||
source.setupPreferenceScreen(sourceScreen)
|
||||
sourceScreen.forEach { pref ->
|
||||
pref.isIconSpaceReserved = false
|
||||
pref.isSingleLineTitle = false
|
||||
if (pref is DialogPreference && pref.dialogTitle.isNullOrEmpty()) {
|
||||
pref.dialogTitle = pref.title
|
||||
}
|
||||
|
||||
// Apply incognito IME for EditTextPreference
|
||||
if (pref is EditTextPreference) {
|
||||
val setListener = pref.getOnBindEditTextListener()
|
||||
pref.setOnBindEditTextListener {
|
||||
setListener?.onBindEditText(it)
|
||||
it.setIncognito(basePreferences, lifecycleScope)
|
||||
}
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
|
||||
return sourceScreen
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SOURCE_ID = "source_id"
|
||||
|
||||
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
||||
return SourcePreferencesFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putLong(SOURCE_ID, sourceId)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
|
||||
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
||||
import eu.kanade.presentation.util.AssistContentScreen
|
||||
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
@@ -98,6 +99,7 @@ import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
|
||||
import eu.kanade.tachiyomi.util.system.updaterEnabled
|
||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -113,7 +115,6 @@ import mihon.app.di.AppGraph
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.core.metro.metroGraph
|
||||
import mihon.core.migration.Migrator
|
||||
import mihon.data.cache.ChapterCache
|
||||
import mihon.domain.database.repository.DatabaseRepository
|
||||
import mihon.feature.support.SupportUsScreen
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
@@ -324,7 +325,9 @@ class MainActivity : BaseActivity() {
|
||||
setSplashScreenExitAnimation(splashScreen)
|
||||
|
||||
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
|
||||
lifecycleScope.launch { chapterCache.clear() }
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
chapterCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,56 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.LAYER_TYPE_HARDWARE
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.transition.doOnEnd
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.presentation.reader.DisplayRefreshHost
|
||||
import eu.kanade.presentation.reader.OrientationSelectDialog
|
||||
import eu.kanade.presentation.reader.ReaderContentOverlay
|
||||
import eu.kanade.presentation.reader.ReaderNavigationOverlay
|
||||
import eu.kanade.presentation.reader.ReaderNavigationOverlayState
|
||||
import eu.kanade.presentation.reader.ReaderPageActionsDialog
|
||||
import eu.kanade.presentation.reader.ReaderPageIndicator
|
||||
import eu.kanade.presentation.reader.ReadingModeSelectDialog
|
||||
@@ -77,8 +58,10 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
|
||||
import eu.kanade.presentation.reader.components.ChapterNavigatorType
|
||||
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
@@ -92,10 +75,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.Action
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
||||
@@ -103,13 +84,14 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.app.di.AppGraph
|
||||
@@ -118,8 +100,9 @@ import tachiyomi.core.common.Constants
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ReaderActivity : BaseActivity() {
|
||||
|
||||
@@ -139,26 +122,23 @@ class ReaderActivity : BaseActivity() {
|
||||
|
||||
@Inject private lateinit var preferences: BasePreferences
|
||||
|
||||
lateinit var binding: ReaderActivityBinding
|
||||
|
||||
val viewModel by viewModels<ReaderViewModel> { graph.viewModelFactory }
|
||||
val settingsViewModel by viewModels<ReaderSettingsViewModel> {
|
||||
viewModelFactory { initializer { ReaderSettingsViewModel(viewModel, readerPreferences, preferences) } }
|
||||
}
|
||||
private var assistUrl: String? = null
|
||||
|
||||
/**
|
||||
* Configuration at reader level, like background color or forced orientation.
|
||||
*/
|
||||
private var config: ReaderConfig? = null
|
||||
|
||||
private var menuToggleToast: Toast? = null
|
||||
private var readingModeToast: Toast? = null
|
||||
private val displayRefreshHost = DisplayRefreshHost()
|
||||
private val navigationOverlayState = ReaderNavigationOverlayState()
|
||||
|
||||
private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, window.decorView) }
|
||||
|
||||
private var isLoadingViewer by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Viewer used to display the pages (pager, webtoon, ...).
|
||||
*/
|
||||
private val viewerFlow = MutableStateFlow<Viewer?>(null)
|
||||
private val viewer: Viewer? inline get() = viewerFlow.value
|
||||
private var loadingIndicator: ReaderProgressIndicator? = null
|
||||
|
||||
var isScrollingThroughPages = false
|
||||
private set
|
||||
@@ -188,140 +168,9 @@ class ReaderActivity : BaseActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setComposeContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val general by settingsViewModel.general.collectAsState()
|
||||
val showPageNumber by readerPreferences.showPageNumber.collectAsState()
|
||||
val flashOnPageChange by readerPreferences.flashOnPageChange.collectAsState()
|
||||
val colorOverlayEnabled by readerPreferences.colorFilter.collectAsState()
|
||||
val colorOverlay by readerPreferences.colorFilterValue.collectAsState()
|
||||
val colorOverlayMode by readerPreferences.colorFilterMode.collectAsState()
|
||||
val brightness by settingsViewModel.brightness.collectAsState()
|
||||
val navigation = settingsViewModel.navigation.collectAsState().value
|
||||
|
||||
val backgroundColor = remember(general.theme) { Color(baseContext.readerBackgroundColor(general.theme)) }
|
||||
val colorOverlayBlendMode = remember(colorOverlayMode) {
|
||||
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
|
||||
}
|
||||
|
||||
LaunchedEffect(brightness) { setWindowBrightness(brightness) }
|
||||
|
||||
val forceNavigationOverlay = remember { settingsViewModel.takeNavigationOverlayForNewUser() }
|
||||
LaunchedEffect(navigation) {
|
||||
if (navigation == null) return@LaunchedEffect
|
||||
val showOnStart = forceNavigationOverlay || settingsViewModel.general.value.navigationOverlayOnStart
|
||||
navigationOverlayState.setNavigation(navigation, showOnStart)
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
.onPreviewKeyEvent(::onKeyEvent)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
navigationOverlayState.hide()
|
||||
}
|
||||
}
|
||||
.then(if (general.keepScreenOn) Modifier.keepScreenOn() else Modifier),
|
||||
) {
|
||||
ReaderViewer()
|
||||
|
||||
if (isLoadingViewer) {
|
||||
LoadingScreen()
|
||||
return@Box
|
||||
}
|
||||
|
||||
ReaderContentOverlay(
|
||||
brightness = brightness,
|
||||
color = colorOverlay.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayBlendMode,
|
||||
)
|
||||
|
||||
if (!state.menuVisible && showPageNumber) {
|
||||
ReaderPageIndicator(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
|
||||
if (flashOnPageChange) {
|
||||
DisplayRefreshHost(hostState = displayRefreshHost)
|
||||
}
|
||||
|
||||
ReaderNavigationOverlay(state = navigationOverlayState)
|
||||
|
||||
AppBars(state = state)
|
||||
}
|
||||
|
||||
if (state.isLoadingAdjacentChapter) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
text = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(stringResource(MR.strings.loading))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val onDismissRequest = viewModel::closeDialog
|
||||
when (state.dialog) {
|
||||
is ReaderViewModel.Dialog.Settings -> {
|
||||
ReaderSettingsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onShowMenus = { setMenuVisibility(true) },
|
||||
onHideMenus = { setMenuVisibility(false) },
|
||||
viewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.ReadingModeSelect -> {
|
||||
ReadingModeSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
viewModel = settingsViewModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
if (!readerPreferences.showReadingMode.get()) {
|
||||
menuToggleToast = toast(stringRes)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.OrientationModeSelect -> {
|
||||
OrientationSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
viewModel = settingsViewModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(stringRes)
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.PageActions -> {
|
||||
ReaderPageActionsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSetAsCover = viewModel::setAsCover,
|
||||
onShare = viewModel::shareImage,
|
||||
onSave = viewModel::saveImage,
|
||||
)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
binding = ReaderActivityBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.setComposeOverlay()
|
||||
|
||||
if (!viewModel.hasValidArgs) {
|
||||
finish()
|
||||
@@ -334,6 +183,7 @@ class ReaderActivity : BaseActivity() {
|
||||
Notifications.ID_NEW_CHAPTERS,
|
||||
)
|
||||
|
||||
config = ReaderConfig()
|
||||
setMenuVisibility(viewModel.state.value.menuVisible)
|
||||
|
||||
// Finish when incognito mode is disabled
|
||||
@@ -349,6 +199,12 @@ class ReaderActivity : BaseActivity() {
|
||||
.onEach(::setInitialChapterError)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.state
|
||||
.map { it.isLoadingAdjacentChapter }
|
||||
.distinctUntilChanged()
|
||||
.onEach(::setProgressDialog)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.state
|
||||
.map { it.manga }
|
||||
.distinctUntilChanged()
|
||||
@@ -392,12 +248,100 @@ class ReaderActivity : BaseActivity() {
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun ReaderActivityBinding.setComposeOverlay(): Unit = composeOverlay.setComposeContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val showPageNumber by readerPreferences.showPageNumber.collectAsState()
|
||||
val settingsviewModel = remember {
|
||||
ReaderSettingsViewModel(
|
||||
readerState = viewModel.state,
|
||||
onChangeReadingMode = viewModel::setMangaReadingMode,
|
||||
onChangeOrientation = viewModel::setMangaOrientationType,
|
||||
preferences = readerPreferences,
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (!state.menuVisible && showPageNumber) {
|
||||
ReaderPageIndicator(
|
||||
currentPage = state.currentPage,
|
||||
totalPages = state.totalPages,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
|
||||
ContentOverlay(state = state)
|
||||
|
||||
AppBars(state = state)
|
||||
}
|
||||
|
||||
val onDismissRequest = viewModel::closeDialog
|
||||
when (state.dialog) {
|
||||
is ReaderViewModel.Dialog.Loading -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
text = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(stringResource(MR.strings.loading))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.Settings -> {
|
||||
ReaderSettingsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onShowMenus = { setMenuVisibility(true) },
|
||||
onHideMenus = { setMenuVisibility(false) },
|
||||
viewModel = settingsviewModel,
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.ReadingModeSelect -> {
|
||||
ReadingModeSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
viewModel = settingsviewModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
if (!readerPreferences.showReadingMode.get()) {
|
||||
menuToggleToast = toast(stringRes)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.OrientationModeSelect -> {
|
||||
OrientationSelectDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
viewModel = settingsviewModel,
|
||||
onChange = { stringRes ->
|
||||
menuToggleToast?.cancel()
|
||||
menuToggleToast = toast(stringRes)
|
||||
},
|
||||
)
|
||||
}
|
||||
is ReaderViewModel.Dialog.PageActions -> {
|
||||
ReaderPageActionsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSetAsCover = viewModel::setAsCover,
|
||||
onShare = viewModel::shareImage,
|
||||
onSave = viewModel::saveImage,
|
||||
)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the activity is destroyed. Cleans up the viewer and any view.
|
||||
* Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewer?.destroy()
|
||||
viewModel.state.value.viewer?.destroy()
|
||||
config = null
|
||||
menuToggleToast?.cancel()
|
||||
readingModeToast?.cancel()
|
||||
}
|
||||
@@ -452,97 +396,59 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_N) {
|
||||
loadNextChapter()
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_P) {
|
||||
loadPreviousChapter()
|
||||
return true
|
||||
}
|
||||
return super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
|
||||
*/
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handled = viewModel.state.value.viewer?.handleKeyEvent(event) ?: false
|
||||
return handled || super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a generic motion event. If the viewer doesn't handle it, call the default
|
||||
* implementation.
|
||||
*/
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
val handled = viewModel.state.value.viewer?.handleGenericMotionEvent(event) ?: false
|
||||
return handled || super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderViewer() {
|
||||
val viewer by viewerFlow.collectAsState()
|
||||
val general by settingsViewModel.general.collectAsState()
|
||||
val insets = when {
|
||||
!general.fullscreen -> WindowInsets.systemBars
|
||||
!general.drawUnderCutout -> WindowInsets.displayCutout
|
||||
else -> WindowInsets(0)
|
||||
}
|
||||
val colorFilter = remember(general.grayscale, general.invertedColors) {
|
||||
viewerColorFilter(general.grayscale, general.invertedColors)
|
||||
private fun ContentOverlay(state: ReaderViewModel.State) {
|
||||
val flashOnPageChange by readerPreferences.flashOnPageChange.collectAsState()
|
||||
|
||||
val colorOverlayEnabled by readerPreferences.colorFilter.collectAsState()
|
||||
val colorOverlay by readerPreferences.colorFilterValue.collectAsState()
|
||||
val colorOverlayMode by readerPreferences.colorFilterMode.collectAsState()
|
||||
val colorOverlayBlendMode = remember(colorOverlayMode) {
|
||||
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
|
||||
}
|
||||
|
||||
// Each viewer filters its page images alone: transition, loading and error pages keep their colours.
|
||||
CompositionLocalProvider(LocalPageColorFilter provides colorFilter) {
|
||||
key(viewer) {
|
||||
viewer?.Content(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(insets),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ReaderContentOverlay(
|
||||
brightness = state.brightnessOverlayValue,
|
||||
color = colorOverlay.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayBlendMode,
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles a key [event] before anything else in the reader. Movement happens on key up.
|
||||
*/
|
||||
private fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
val viewer = viewer ?: return false
|
||||
val navigation = settingsViewModel.navigation.value ?: return false
|
||||
val isUp = event.type == KeyEventType.KeyUp
|
||||
val action = when (event.key) {
|
||||
Key.VolumeDown, Key.VolumeUp -> {
|
||||
val general = settingsViewModel.general.value
|
||||
if (!general.volumeKeysEnabled || viewModel.state.value.menuVisible) return false
|
||||
if ((event.key == Key.VolumeDown) != general.volumeKeysInverted) Action.FORWARD else Action.BACKWARD
|
||||
}
|
||||
Key.DirectionRight -> if (event.isCtrlPressed) {
|
||||
Action.FORWARD
|
||||
} else {
|
||||
navigation.actionOf(
|
||||
NavigationRegion.RIGHT,
|
||||
)
|
||||
}
|
||||
Key.DirectionLeft -> if (event.isCtrlPressed) {
|
||||
Action.BACKWARD
|
||||
} else {
|
||||
navigation.actionOf(
|
||||
NavigationRegion.LEFT,
|
||||
)
|
||||
}
|
||||
Key.DirectionDown, Key.PageDown -> Action.FORWARD
|
||||
Key.DirectionUp, Key.PageUp -> Action.BACKWARD
|
||||
Key.Menu -> Action.MENU
|
||||
Key.N -> {
|
||||
if (isUp) loadNextChapter()
|
||||
return true
|
||||
}
|
||||
Key.P -> {
|
||||
if (isUp) loadPreviousChapter()
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
if (isUp) {
|
||||
when (action) {
|
||||
Action.MENU -> toggleMenu()
|
||||
Action.FORWARD -> viewer.moveForward()
|
||||
Action.BACKWARD -> viewer.moveBackward()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the viewer when it's tapped at [pos], given as a fraction of its size.
|
||||
*/
|
||||
fun onViewerTap(pos: Offset) {
|
||||
val action = settingsViewModel.navigation.value?.getAction(pos) ?: Action.MENU
|
||||
when (action) {
|
||||
Action.MENU -> toggleMenu()
|
||||
Action.FORWARD -> viewer?.moveForward()
|
||||
Action.BACKWARD -> viewer?.moveBackward()
|
||||
if (flashOnPageChange) {
|
||||
DisplayRefreshHost(hostState = displayRefreshHost)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBars(state: ReaderViewModel.State) {
|
||||
val isHttpSource = state.source is HttpSource
|
||||
val readingMode = ReadingMode.fromPreference(viewModel.getMangaReadingMode())
|
||||
|
||||
val cropBorderPaged by readerPreferences.cropBorders.collectAsState()
|
||||
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon.collectAsState()
|
||||
@@ -550,7 +456,9 @@ class ReaderActivity : BaseActivity() {
|
||||
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
|
||||
|
||||
val verticalNavigatorModes by readerPreferences.verticalNavigator.collectAsState()
|
||||
val verticalNavigator = verticalNavigatorModes.contains(readingMode)
|
||||
val verticalNavigator = verticalNavigatorModes.contains(
|
||||
ReadingMode.fromPreference(viewModel.getMangaReadingMode()),
|
||||
)
|
||||
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
|
||||
val verticalNavigatorHeight by readerPreferences.verticalNavigatorHeight.collectAsState()
|
||||
|
||||
@@ -568,7 +476,7 @@ class ReaderActivity : BaseActivity() {
|
||||
onShare = ::shareChapter.takeIf { isHttpSource },
|
||||
|
||||
chapterNavigatorType = if (!verticalNavigator) {
|
||||
if (readingMode == ReadingMode.RIGHT_TO_LEFT) {
|
||||
if (state.viewer is R2LPagerViewer) {
|
||||
ChapterNavigatorType.HORIZONTAL_RTL
|
||||
} else {
|
||||
ChapterNavigatorType.HORIZONTAL_LTR
|
||||
@@ -629,22 +537,35 @@ class ReaderActivity : BaseActivity() {
|
||||
* Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer.
|
||||
*/
|
||||
private fun updateViewer() {
|
||||
val prevViewer = viewer
|
||||
val readingMode = viewModel.getMangaReadingMode()
|
||||
settingsViewModel.setViewerReadingMode(ReadingMode.fromPreference(readingMode))
|
||||
val newViewer = ReadingMode.toViewer(readingMode, this)
|
||||
val prevViewer = viewModel.state.value.viewer
|
||||
val newViewer = ReadingMode.toViewer(viewModel.getMangaReadingMode(), this)
|
||||
|
||||
setOrientation(viewModel.getMangaOrientation())
|
||||
if (window.sharedElementEnterTransition is MaterialContainerTransform) {
|
||||
// Wait until transition is complete to avoid crash on API 26
|
||||
window.sharedElementEnterTransition.doOnEnd {
|
||||
setOrientation(viewModel.getMangaOrientation())
|
||||
}
|
||||
} else {
|
||||
setOrientation(viewModel.getMangaOrientation())
|
||||
}
|
||||
|
||||
// Destroy previous viewer if there was one
|
||||
prevViewer?.destroy()
|
||||
viewerFlow.value = newViewer
|
||||
if (prevViewer != null) {
|
||||
prevViewer.destroy()
|
||||
binding.viewerContainer.removeAllViews()
|
||||
}
|
||||
viewModel.onViewerLoaded(newViewer)
|
||||
updateViewerInset(readerPreferences.fullscreen.get(), readerPreferences.drawUnderCutout.get())
|
||||
binding.viewerContainer.addView(newViewer.getView())
|
||||
|
||||
if (readerPreferences.showReadingMode.get()) {
|
||||
showReadingModeToast(viewModel.getMangaReadingMode())
|
||||
}
|
||||
|
||||
isLoadingViewer = true
|
||||
loadingIndicator = ReaderProgressIndicator(this)
|
||||
binding.readerContainer.addView(loadingIndicator)
|
||||
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
|
||||
private fun openMangaScreen() {
|
||||
@@ -695,9 +616,10 @@ class ReaderActivity : BaseActivity() {
|
||||
* method to the current viewer, but also set the subtitle on the toolbar, and
|
||||
* hides or disables the reader prev/next buttons if there's a prev or next chapter
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun setChapters(viewerChapters: ViewerChapters) {
|
||||
isLoadingViewer = false
|
||||
viewer?.setChapters(viewerChapters)
|
||||
binding.readerContainer.removeView(loadingIndicator)
|
||||
viewModel.state.value.viewer?.setChapters(viewerChapters)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.getChapterUrl()?.let { url ->
|
||||
@@ -716,12 +638,26 @@ class ReaderActivity : BaseActivity() {
|
||||
toast(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter whenever it's loading the next or previous chapter. It shows or
|
||||
* dismisses a non-cancellable dialog to prevent user interaction according to the value of
|
||||
* [show]. This is only used when the next/previous buttons on the toolbar are clicked; the
|
||||
* other cases are handled with chapter transitions on the viewers and chapter preloading.
|
||||
*/
|
||||
private fun setProgressDialog(show: Boolean) {
|
||||
if (show) {
|
||||
viewModel.showLoadingDialog()
|
||||
} else {
|
||||
viewModel.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
|
||||
* page is not found.
|
||||
*/
|
||||
private fun moveToPageIndex(index: Int) {
|
||||
val viewer = this.viewer ?: return
|
||||
val viewer = viewModel.state.value.viewer ?: return
|
||||
val currentChapter = viewModel.state.value.currentChapter ?: return
|
||||
val page = currentChapter.pages?.getOrNull(index) ?: return
|
||||
viewer.moveToPage(page)
|
||||
@@ -860,41 +796,172 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 the minimum brightness is used, and the overlay darkens further.
|
||||
* From 1 to 100 it sets that value as brightness.
|
||||
* 0 sets system brightness.
|
||||
* Updates viewer inset depending on fullscreen reader preferences.
|
||||
*/
|
||||
private fun setWindowBrightness(value: Int) {
|
||||
// Calculate and set reader brightness.
|
||||
val readerBrightness = when {
|
||||
value > 0 -> {
|
||||
value / 100f
|
||||
}
|
||||
value < 0 -> {
|
||||
0.01f
|
||||
}
|
||||
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
}
|
||||
}
|
||||
private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
|
||||
if (!::binding.isInitialized) return
|
||||
val view = binding.viewerContainer
|
||||
|
||||
private fun viewerColorFilter(grayscale: Boolean, invertedColors: Boolean): ColorFilter? {
|
||||
if (!grayscale && !invertedColors) return null
|
||||
val matrix = ColorMatrix()
|
||||
if (invertedColors) {
|
||||
matrix *= ColorMatrix(
|
||||
floatArrayOf(
|
||||
-1f, 0f, 0f, 0f, 255f,
|
||||
0f, -1f, 0f, 0f, 255f,
|
||||
0f, 0f, -1f, 0f, 255f,
|
||||
0f, 0f, 0f, 1f, 0f,
|
||||
),
|
||||
)
|
||||
view.applyInsetsPadding(ViewCompat.getRootWindowInsets(view), fullscreen, drawUnderCutout)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
|
||||
view.applyInsetsPadding(windowInsets, fullscreen, drawUnderCutout)
|
||||
windowInsets
|
||||
}
|
||||
}
|
||||
if (grayscale) {
|
||||
matrix *= ColorMatrix().apply { setToSaturation(0f) }
|
||||
|
||||
private fun View.applyInsetsPadding(
|
||||
windowInsets: WindowInsetsCompat?,
|
||||
fullscreen: Boolean,
|
||||
drawUnderCutout: Boolean,
|
||||
) {
|
||||
val insets = when {
|
||||
!fullscreen -> windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
!drawUnderCutout -> windowInsets?.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
else -> null
|
||||
}
|
||||
?: Insets.NONE
|
||||
|
||||
setPadding(insets.left, insets.top, insets.right, insets.bottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that handles the user preferences of the reader.
|
||||
*/
|
||||
private inner class ReaderConfig {
|
||||
|
||||
private fun getCombinedPaint(grayscale: Boolean, invertedColors: Boolean): Paint {
|
||||
return Paint().apply {
|
||||
colorFilter = ColorMatrixColorFilter(
|
||||
ColorMatrix().apply {
|
||||
if (grayscale) {
|
||||
setSaturation(0f)
|
||||
}
|
||||
if (invertedColors) {
|
||||
postConcat(
|
||||
ColorMatrix(
|
||||
floatArrayOf(
|
||||
-1f, 0f, 0f, 0f, 255f,
|
||||
0f, -1f, 0f, 0f, 255f,
|
||||
0f, 0f, -1f, 0f, 255f,
|
||||
0f, 0f, 0f, 1f, 0f,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Initializes the reader subscriptions.
|
||||
*/
|
||||
init {
|
||||
readerPreferences.readerTheme.changes()
|
||||
.onEach { theme ->
|
||||
binding.readerContainer.setBackgroundColor(baseContext.readerBackgroundColor(theme))
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
preferences.displayProfile.changes()
|
||||
.onEach { setDisplayProfile(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.keepScreenOn.changes()
|
||||
.onEach(::setKeepScreenOn)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.customBrightness.changes()
|
||||
.onEach(::setCustomBrightness)
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
combine(
|
||||
readerPreferences.grayscale.changes(),
|
||||
readerPreferences.invertedColors.changes(),
|
||||
) { grayscale, invertedColors -> grayscale to invertedColors }
|
||||
.onEach { (grayscale, invertedColors) ->
|
||||
setLayerPaint(grayscale, invertedColors)
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
combine(
|
||||
readerPreferences.fullscreen.changes(),
|
||||
readerPreferences.drawUnderCutout.changes(),
|
||||
) { fullscreen, drawUnderCutout -> fullscreen to drawUnderCutout }
|
||||
.onEach { (fullscreen, drawUnderCutout) ->
|
||||
updateViewerInset(fullscreen, drawUnderCutout)
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the display profile to [path].
|
||||
*/
|
||||
private fun setDisplayProfile(path: String) {
|
||||
val file = UniFile.fromUri(baseContext, path.toUri())
|
||||
if (file != null && file.exists()) {
|
||||
val inputStream = file.openInputStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
inputStream.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val data = outputStream.toByteArray()
|
||||
SubsamplingScaleImageView.setDisplayProfile(data)
|
||||
ImageDecoder.displayProfile = data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the keep screen on mode according to [enabled].
|
||||
*/
|
||||
private fun setKeepScreenOn(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom brightness overlay according to [enabled].
|
||||
*/
|
||||
private fun setCustomBrightness(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
readerPreferences.customBrightnessValue.changes()
|
||||
.sample(0.1.seconds)
|
||||
.onEach(::setCustomBrightnessValue)
|
||||
.launchIn(lifecycleScope)
|
||||
} else {
|
||||
setCustomBrightnessValue(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
|
||||
* From 1 to 100 it sets that value as brightness.
|
||||
* 0 sets system brightness and hides the overlay.
|
||||
*/
|
||||
private fun setCustomBrightnessValue(value: Int) {
|
||||
// Calculate and set reader brightness.
|
||||
val readerBrightness = when {
|
||||
value > 0 -> {
|
||||
value / 100f
|
||||
}
|
||||
value < 0 -> {
|
||||
0.01f
|
||||
}
|
||||
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
|
||||
viewModel.setBrightnessOverlayValue(value)
|
||||
}
|
||||
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
|
||||
val paint = if (grayscale || invertedColors) getCombinedPaint(grayscale, invertedColors) else null
|
||||
binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint)
|
||||
}
|
||||
}
|
||||
return ColorFilter.colorMatrix(matrix)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
import androidx.core.graphics.withScale
|
||||
import androidx.core.graphics.withTranslation
|
||||
import androidx.core.view.isVisible
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import kotlin.math.abs
|
||||
|
||||
class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
|
||||
|
||||
private var viewPropertyAnimator: ViewPropertyAnimator? = null
|
||||
|
||||
private var navigation: ViewerNavigation? = null
|
||||
|
||||
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
|
||||
val firstLaunch = this.navigation == null
|
||||
this.navigation = navigation
|
||||
invalidate()
|
||||
|
||||
if (isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
|
||||
return
|
||||
}
|
||||
|
||||
viewPropertyAnimator = animate()
|
||||
.alpha(1f)
|
||||
.setDuration(FADE_DURATION)
|
||||
.withStartAction {
|
||||
isVisible = true
|
||||
}
|
||||
.withEndAction {
|
||||
viewPropertyAnimator = null
|
||||
}
|
||||
viewPropertyAnimator?.start()
|
||||
}
|
||||
|
||||
private val regionPaint = Paint()
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = Color.WHITE
|
||||
textSize = 64f
|
||||
}
|
||||
|
||||
private val textBorderPaint = Paint().apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = Color.BLACK
|
||||
textSize = 64f
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 8f
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (navigation == null) return
|
||||
|
||||
navigation?.getRegions()?.forEach { region ->
|
||||
val rect = region.rectF
|
||||
|
||||
// Scale rect from 1f,1f to screen width and height
|
||||
canvas.withScale(width.toFloat(), height.toFloat()) {
|
||||
regionPaint.color = region.type.color
|
||||
drawRect(rect, regionPaint)
|
||||
}
|
||||
|
||||
// Don't want scale anymore because it messes with drawText
|
||||
// Translate origin to rect start (left, top)
|
||||
canvas.withTranslation(x = (width * rect.left), y = (height * rect.top)) {
|
||||
// Calculate center of rect width on screen
|
||||
val x = width * (abs(rect.left - rect.right) / 2)
|
||||
|
||||
// Calculate center of rect height on screen
|
||||
val y = height * (abs(rect.top - rect.bottom) / 2)
|
||||
|
||||
drawText(context.stringResource(region.type.nameRes), x, y, textBorderPaint)
|
||||
drawText(context.stringResource(region.type.nameRes), x, y, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
super.performClick()
|
||||
|
||||
if (viewPropertyAnimator == null && isVisible) {
|
||||
viewPropertyAnimator = animate()
|
||||
.alpha(0f)
|
||||
.setDuration(FADE_DURATION)
|
||||
.withEndAction {
|
||||
isVisible = false
|
||||
viewPropertyAnimator = null
|
||||
}
|
||||
viewPropertyAnimator?.start()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
// Hide overlay if user start tapping or swiping
|
||||
performClick()
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private const val FADE_DURATION = 1000L
|
||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -24,6 +25,7 @@ import eu.kanade.domain.manga.model.readingMode
|
||||
import eu.kanade.domain.source.interactor.GetIncognitoState
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
@@ -45,6 +47,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.util.chapter.filterDownloaded
|
||||
import eu.kanade.tachiyomi.util.chapter.removeDuplicates
|
||||
import eu.kanade.tachiyomi.util.editCover
|
||||
@@ -75,7 +78,6 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
import mihon.data.cache.ChapterCache
|
||||
import tachiyomi.core.common.preference.toggle
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
@@ -525,6 +527,12 @@ class ReaderViewModel(
|
||||
eventChannel.trySend(Event.ReloadViewerChapters)
|
||||
}
|
||||
|
||||
fun onViewerLoaded(viewer: Viewer?) {
|
||||
mutableState.update {
|
||||
it.copy(viewer = viewer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every time a page changes on the reader. Used to mark the flag of chapters being
|
||||
* read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this
|
||||
@@ -912,6 +920,10 @@ class ReaderViewModel(
|
||||
mutableState.update { it.copy(menuVisible = visible) }
|
||||
}
|
||||
|
||||
fun showLoadingDialog() {
|
||||
mutableState.update { it.copy(dialog = Dialog.Loading) }
|
||||
}
|
||||
|
||||
fun openReadingModeSelectDialog() {
|
||||
mutableState.update { it.copy(dialog = Dialog.ReadingModeSelect) }
|
||||
}
|
||||
@@ -932,6 +944,10 @@ class ReaderViewModel(
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
fun setBrightnessOverlayValue(value: Int) {
|
||||
mutableState.update { it.copy(brightnessOverlayValue = value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of the selected page on the pictures directory and notifies the UI of the result.
|
||||
* There's also a notification to allow sharing the image somewhere else or deleting it.
|
||||
@@ -1094,8 +1110,14 @@ class ReaderViewModel(
|
||||
val bookmarked: Boolean = false,
|
||||
val isLoadingAdjacentChapter: Boolean = false,
|
||||
val currentPage: Int = -1,
|
||||
|
||||
/**
|
||||
* Viewer used to display the pages (pager, webtoon, ...).
|
||||
*/
|
||||
val viewer: Viewer? = null,
|
||||
val dialog: Dialog? = null,
|
||||
val menuVisible: Boolean = false,
|
||||
@IntRange(from = -100, to = 100) val brightnessOverlayValue: Int = 0,
|
||||
) {
|
||||
val currentChapter: ReaderChapter?
|
||||
get() = viewerChapters?.currChapter
|
||||
@@ -1105,6 +1127,7 @@ class ReaderViewModel(
|
||||
}
|
||||
|
||||
sealed interface Dialog {
|
||||
data object Loading : Dialog
|
||||
data object Settings : Dialog
|
||||
data object ReadingModeSelect : Dialog
|
||||
data object OrientationModeSelect : Dialog
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.domain.chapter.model.toSChapter
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.core.archive.archiveReader
|
||||
import mihon.core.archive.epubReader
|
||||
import mihon.data.cache.ChapterCache
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.domain.chapter.model.toSChapter
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
@@ -15,8 +16,6 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.data.cache.ChapterCache
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -42,8 +41,14 @@ internal class HttpPageLoader(
|
||||
* otherwise fallbacks to network.
|
||||
*/
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
val pages = chapterCache.getPageList(chapter.chapter)
|
||||
?: source.getPageList(chapter.chapter.toSChapter())
|
||||
val pages = try {
|
||||
chapterCache.getPageListFromCache(chapter.chapter)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
source.getPageList(chapter.chapter.toSChapter())
|
||||
}
|
||||
return pages.mapIndexed { index, page ->
|
||||
// Don't trust sources and use our own indexing
|
||||
ReaderPage(index, page.url, page.imageUrl).also {
|
||||
@@ -109,7 +114,7 @@ internal class HttpPageLoader(
|
||||
try {
|
||||
// Convert to pages without reader information
|
||||
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
|
||||
chapterCache.putPageList(chapter.chapter, pagesToSave)
|
||||
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
@@ -225,32 +230,15 @@ internal class HttpPageLoader(
|
||||
}
|
||||
val imageUrl = page.imageUrl!!
|
||||
|
||||
val file = chapterCache.getImageFile(imageUrl).takeUnless { force } ?: run {
|
||||
val live = page.downloadStream
|
||||
if (force || !chapterCache.isImageInCache(imageUrl)) {
|
||||
page.status = Page.State.DownloadImage
|
||||
try {
|
||||
chapterCache.putImage(imageUrl, source.getImage(page), live)
|
||||
.also { live?.finish() }
|
||||
} catch (e: Throwable) {
|
||||
live?.finish(e)
|
||||
throw e
|
||||
}
|
||||
val imageResponse = source.getImage(page)
|
||||
chapterCache.putImageToCache(imageUrl, imageResponse)
|
||||
}
|
||||
|
||||
page.downloadStream = null
|
||||
page.stream = {
|
||||
try {
|
||||
file.inputStream()
|
||||
} catch (e: FileNotFoundException) {
|
||||
// Gone from the cache since, so it's fetched again and shown once it's back
|
||||
page.status = Page.State.Queue
|
||||
launchLoadOnce(page)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
||||
page.status = Page.State.Ready
|
||||
} catch (e: Throwable) {
|
||||
page.downloadStream = null
|
||||
if (e is CancellationException) {
|
||||
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
|
||||
// the queue, otherwise it's stuck in a state no later pass will pick up.
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
|
||||
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
|
||||
*/
|
||||
class DownloadStream {
|
||||
private val lock = Object()
|
||||
private var data = ByteArray(1 shl 16)
|
||||
private var size = 0
|
||||
private var done = false
|
||||
private var error: Throwable? = null
|
||||
|
||||
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
|
||||
fun reserve(bytes: Long) {
|
||||
synchronized(lock) {
|
||||
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
|
||||
try {
|
||||
data = data.copyOf(bytes.toInt())
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// Only a hint.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun append(src: ByteArray, offset: Int, length: Int) {
|
||||
synchronized(lock) {
|
||||
if (error != null || done) return
|
||||
val needed = size.toLong() + length
|
||||
if (needed > MAX_BYTES) {
|
||||
// Too big to decode while downloading; the whole-file decode takes over.
|
||||
drop("Download too large to stream")
|
||||
return
|
||||
}
|
||||
if (needed > data.size) {
|
||||
data = try {
|
||||
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// As too large: the download mustn't fail over this copy.
|
||||
drop("Out of memory buffering the download")
|
||||
return
|
||||
}
|
||||
}
|
||||
System.arraycopy(src, offset, data, size, length)
|
||||
size += length
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the bytes; readers throw [error] if given. */
|
||||
fun finish(error: Throwable? = null) {
|
||||
synchronized(lock) {
|
||||
done = true
|
||||
if (this.error == null) this.error = error
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
|
||||
fun reader(): Reader = Reader()
|
||||
|
||||
// Rather than hold a second copy of the page.
|
||||
private fun drop(why: String) {
|
||||
if (error == null) error = IOException(why)
|
||||
data = ByteArray(0)
|
||||
size = 0
|
||||
lock.notifyAll()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// As the decoder's own input limit.
|
||||
const val MAX_BYTES = 1L shl 30
|
||||
const val MAX_RESERVE = 64L shl 20
|
||||
}
|
||||
|
||||
inner class Reader : InputStream() {
|
||||
private var position = 0
|
||||
|
||||
/** All read; throws if the download failed. */
|
||||
val ended: Boolean
|
||||
get() = synchronized(lock) {
|
||||
checkError()
|
||||
done && position >= size
|
||||
}
|
||||
|
||||
override fun read(): Int {
|
||||
val b = ByteArray(1)
|
||||
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
synchronized(lock) {
|
||||
if (len == 0) return 0
|
||||
while (position >= size && !done && error == null) lock.wait()
|
||||
checkError()
|
||||
if (position >= size) return -1
|
||||
val n = minOf(len, size - position)
|
||||
System.arraycopy(data, position, b, off, n)
|
||||
position += n
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
|
||||
|
||||
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
|
||||
fun await(min: Int, timeoutMs: Long) {
|
||||
synchronized(lock) {
|
||||
val until = System.nanoTime() + timeoutMs * 1_000_000
|
||||
while (size - position < min && !done && error == null) {
|
||||
val left = (until - System.nanoTime()) / 1_000_000
|
||||
if (left <= 0) return
|
||||
lock.wait(left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) { drop("No longer read") }
|
||||
}
|
||||
|
||||
private fun checkError() {
|
||||
error?.let { throw IOException("Download failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,4 @@ open class ReaderPage(
|
||||
) : Page(index, url, imageUrl, null) {
|
||||
|
||||
open lateinit var chapter: ReaderChapter
|
||||
|
||||
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
|
||||
@Volatile
|
||||
var downloadStream: DownloadStream? = null
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.setting
|
||||
|
||||
/**
|
||||
* Settings for the reader as a whole and for every viewer alike.
|
||||
*/
|
||||
data class GeneralSettings(
|
||||
val theme: ReaderPreferences.ReaderTheme,
|
||||
val keepScreenOn: Boolean,
|
||||
val grayscale: Boolean,
|
||||
val invertedColors: Boolean,
|
||||
val fullscreen: Boolean,
|
||||
val drawUnderCutout: Boolean,
|
||||
val longTapEnabled: Boolean,
|
||||
val usePageTransitions: Boolean,
|
||||
val doubleTapAnimDuration: Int,
|
||||
val volumeKeysEnabled: Boolean,
|
||||
val volumeKeysInverted: Boolean,
|
||||
val alwaysShowChapterTransition: Boolean,
|
||||
val navigationOverlayOnStart: Boolean,
|
||||
) {
|
||||
val automaticBackground: Boolean
|
||||
get() = theme == ReaderPreferences.ReaderTheme.AUTOMATIC
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import dev.icerock.moko.resources.StringResource
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShaders
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.preference.getEnum
|
||||
@@ -144,11 +142,6 @@ class ReaderPreferences(
|
||||
false,
|
||||
)
|
||||
|
||||
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
|
||||
"pref_dual_page_view",
|
||||
DualPageView.NEVER,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Color filter
|
||||
@@ -206,43 +199,6 @@ class ReaderPreferences(
|
||||
|
||||
// endregion
|
||||
|
||||
// region WebGpu
|
||||
|
||||
val transitionAnimation: Preference<TransitionAnimation> =
|
||||
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC)
|
||||
|
||||
val transitionAnimationDual: Preference<TransitionAnimation> =
|
||||
preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC)
|
||||
|
||||
val cutoutMode: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID)
|
||||
|
||||
val cutoutModeDual: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE)
|
||||
|
||||
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
|
||||
|
||||
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
|
||||
|
||||
val disableDoubleTapGestures: Preference<Boolean> =
|
||||
preferenceStore.getBoolean("webgpu_disable_double_tap_gestures", false)
|
||||
|
||||
/** In run order. */
|
||||
val imageFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
|
||||
"webgpu_image_filters",
|
||||
emptyList(),
|
||||
WebGpuShaders::serialize,
|
||||
WebGpuShaders::deserialize,
|
||||
)
|
||||
|
||||
/** In run order. */
|
||||
val tileFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
|
||||
"webgpu_tile_filters",
|
||||
emptyList(),
|
||||
WebGpuShaders::serialize,
|
||||
WebGpuShaders::deserialize,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
enum class FlashColor {
|
||||
BLACK,
|
||||
WHITE,
|
||||
@@ -255,81 +211,11 @@ class ReaderPreferences(
|
||||
val shouldInvertVertical: Boolean = false,
|
||||
) {
|
||||
NONE(MR.strings.tapping_inverted_none),
|
||||
HORIZONTAL(
|
||||
MR.strings.tapping_inverted_horizontal,
|
||||
shouldInvertHorizontal = true,
|
||||
),
|
||||
VERTICAL(
|
||||
MR.strings.tapping_inverted_vertical,
|
||||
shouldInvertVertical = true,
|
||||
),
|
||||
HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true),
|
||||
VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true),
|
||||
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
|
||||
}
|
||||
|
||||
/**
|
||||
* The [readerTheme] preference, stored as its [value].
|
||||
*/
|
||||
enum class ReaderTheme(val value: Int) {
|
||||
WHITE(0),
|
||||
BLACK(1),
|
||||
GRAY(2),
|
||||
AUTOMATIC(3),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = entries.find { it.value == value } ?: BLACK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The [imageScaleType] preference, stored as its [value], which is also the scale type the image view takes.
|
||||
*/
|
||||
enum class ScaleType(val value: Int) {
|
||||
FIT_SCREEN(1),
|
||||
STRETCH(2),
|
||||
FIT_WIDTH(3),
|
||||
FIT_HEIGHT(4),
|
||||
ORIGINAL_SIZE(5),
|
||||
SMART_FIT(6),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = entries.find { it.value == value } ?: FIT_SCREEN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The [zoomStart] preference, stored as its [value].
|
||||
*/
|
||||
enum class ZoomStartType(val value: Int) {
|
||||
AUTOMATIC(1),
|
||||
LEFT(2),
|
||||
RIGHT(3),
|
||||
CENTER(4),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = entries.find { it.value == value } ?: CENTER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The [navigationModePager] and [navigationModeWebtoon] preferences, stored as their [value].
|
||||
*/
|
||||
enum class NavigationMode(val value: Int) {
|
||||
DEFAULT(0),
|
||||
L_SHAPED(1),
|
||||
KINDLISH(2),
|
||||
EDGE(3),
|
||||
RIGHT_AND_LEFT(4),
|
||||
DISABLED(5),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = entries.find { it.value == value } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReaderHideThreshold(val threshold: Int) {
|
||||
HIGHEST(5),
|
||||
HIGH(13),
|
||||
@@ -337,43 +223,6 @@ class ReaderPreferences(
|
||||
LOWEST(47),
|
||||
}
|
||||
|
||||
enum class TransitionAnimation(val titleRes: StringResource) {
|
||||
BASIC(MR.strings.transition_animation_basic),
|
||||
FLIP(MR.strings.transition_animation_flip),
|
||||
FLIP_LEFT(MR.strings.transition_animation_flip_left),
|
||||
FLIP_RIGHT(
|
||||
MR.strings.transition_animation_flip_right,
|
||||
),
|
||||
STACK_LEFT(MR.strings.transition_animation_stack_left),
|
||||
STACK_RIGHT(MR.strings.transition_animation_stack_right),
|
||||
STACK_UP(
|
||||
MR.strings.transition_animation_stack_up,
|
||||
),
|
||||
STACK_DOWN(MR.strings.transition_animation_stack_down),
|
||||
SPHERE(MR.strings.transition_animation_sphere),
|
||||
CUBE_INSIDE(
|
||||
MR.strings.transition_animation_cube_inside,
|
||||
),
|
||||
CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside),
|
||||
FADE(MR.strings.transition_animation_fade),
|
||||
FADE_WHITE(
|
||||
MR.strings.transition_animation_fade_white,
|
||||
),
|
||||
NONE(MR.strings.transition_animation_none),
|
||||
}
|
||||
|
||||
enum class CutoutMode(val titleRes: StringResource) {
|
||||
IGNORE(MR.strings.cutout_mode_ignore),
|
||||
AVOID(MR.strings.cutout_mode_avoid),
|
||||
SHIFT(MR.strings.cutout_mode_shift),
|
||||
}
|
||||
|
||||
enum class DualPageView(val titleRes: StringResource) {
|
||||
NEVER(MR.strings.dual_page_view_never),
|
||||
ALWAYS(MR.strings.dual_page_view_always),
|
||||
WIDE(MR.strings.dual_page_view_wide),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WEBTOON_PADDING_MIN = 0
|
||||
const val WEBTOON_PADDING_MAX = 25
|
||||
@@ -398,13 +247,6 @@ class ReaderPreferences(
|
||||
MR.strings.scale_type_smart_fit,
|
||||
)
|
||||
|
||||
val ImageScaleTypeWebGpuViewer = listOf(
|
||||
MR.strings.scale_type_fit_screen,
|
||||
MR.strings.scale_type_fit_width,
|
||||
MR.strings.scale_type_fit_height,
|
||||
MR.strings.scale_type_original_size,
|
||||
)
|
||||
|
||||
val ZoomStart = listOf(
|
||||
MR.strings.zoom_start_automatic,
|
||||
MR.strings.zoom_start_left,
|
||||
|
||||
+9
-299
@@ -2,317 +2,27 @@ package eu.kanade.tachiyomi.ui.reader.setting
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.manga.model.readingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.LongStripSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.PagedSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.SimplePagedSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuLongStripSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuPagedSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.SimpleLongStripSettings
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The reader's settings, each as one value re-read whenever a preference it's made of changes, so the activity and
|
||||
* viewers apply a change in one place instead of each preference calling back.
|
||||
*/
|
||||
class ReaderSettingsViewModel(
|
||||
private val readerViewModel: ReaderViewModel,
|
||||
readerState: StateFlow<ReaderViewModel.State>,
|
||||
val onChangeReadingMode: (ReadingMode) -> Unit,
|
||||
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
||||
val preferences: ReaderPreferences,
|
||||
private val basePreferences: BasePreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
val mangaFlow = readerViewModel.state
|
||||
.map { it.manga }
|
||||
val viewerFlow = readerState
|
||||
.map { it.viewer }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||
|
||||
/**
|
||||
* The reading mode the viewer is made for, with the default resolved.
|
||||
*/
|
||||
val readingMode = combine(mangaFlow, preferences.defaultReadingMode.changes()) { manga, default ->
|
||||
ReadingMode.fromPreference(manga?.readingMode?.toInt())
|
||||
.takeIf { it != ReadingMode.DEFAULT }
|
||||
?: ReadingMode.fromPreference(default)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
ReadingMode.fromPreference(readerViewModel.getMangaReadingMode()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the viewers are the WebGPU ones.
|
||||
*/
|
||||
val isWebGpu = basePreferences.highQualityRenderer
|
||||
|
||||
val general = settings(
|
||||
preferences.readerTheme,
|
||||
preferences.keepScreenOn,
|
||||
preferences.grayscale,
|
||||
preferences.invertedColors,
|
||||
preferences.fullscreen,
|
||||
preferences.drawUnderCutout,
|
||||
preferences.readWithLongTap,
|
||||
preferences.pageTransitions,
|
||||
preferences.doubleTapAnimSpeed,
|
||||
preferences.readWithVolumeKeys,
|
||||
preferences.readWithVolumeKeysInverted,
|
||||
preferences.alwaysShowChapterTransition,
|
||||
preferences.showNavigationOverlayOnStart,
|
||||
) {
|
||||
GeneralSettings(
|
||||
theme = ReaderPreferences.ReaderTheme.fromValue(readerTheme.get()),
|
||||
keepScreenOn = keepScreenOn.get(),
|
||||
grayscale = grayscale.get(),
|
||||
invertedColors = invertedColors.get(),
|
||||
fullscreen = fullscreen.get(),
|
||||
drawUnderCutout = drawUnderCutout.get(),
|
||||
longTapEnabled = readWithLongTap.get(),
|
||||
usePageTransitions = pageTransitions.get(),
|
||||
doubleTapAnimDuration = doubleTapAnimSpeed.get(),
|
||||
volumeKeysEnabled = readWithVolumeKeys.get(),
|
||||
volumeKeysInverted = readWithVolumeKeysInverted.get(),
|
||||
alwaysShowChapterTransition = alwaysShowChapterTransition.get(),
|
||||
navigationOverlayOnStart = showNavigationOverlayOnStart.get(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom brightness, 0 when it's off.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
val brightness: StateFlow<Int> = preferences.customBrightness.changes()
|
||||
.flatMapLatest { enabled ->
|
||||
if (enabled) preferences.customBrightnessValue.changes().sample(0.1.seconds) else flowOf(0)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
if (preferences.customBrightness.get()) preferences.customBrightnessValue.get() else 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Settings every paged viewer uses.
|
||||
*/
|
||||
val paged = settings(
|
||||
preferences.imageScaleType,
|
||||
preferences.zoomStart,
|
||||
preferences.cropBorders,
|
||||
preferences.landscapeZoom,
|
||||
preferences.navigateToPan,
|
||||
preferences.navigationModePager,
|
||||
preferences.pagerNavInverted,
|
||||
) {
|
||||
PagedSettings(
|
||||
page = PagedSettings.Page(
|
||||
imageScaleType = ReaderPreferences.ScaleType.fromValue(imageScaleType.get()),
|
||||
zoomStart = ReaderPreferences.ZoomStartType.fromValue(zoomStart.get()),
|
||||
cropBorders = cropBorders.get(),
|
||||
landscapeZoom = landscapeZoom.get(),
|
||||
),
|
||||
navigateToPan = navigateToPan.get(),
|
||||
navigation = NavigationSettings(
|
||||
ReaderPreferences.NavigationMode.fromValue(navigationModePager.get()),
|
||||
pagerNavInverted.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings every long strip viewer uses.
|
||||
*/
|
||||
val longStrip = settings(
|
||||
preferences.cropBordersWebtoon,
|
||||
preferences.webtoonDisableZoomOut,
|
||||
) {
|
||||
LongStripSettings(
|
||||
cropBorders = cropBordersWebtoon.get(),
|
||||
zoomOutDisabled = webtoonDisableZoomOut.get(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings only the pager viewers use.
|
||||
*/
|
||||
val simplePaged = settings(
|
||||
preferences.dualPageSplitPaged,
|
||||
preferences.dualPageInvertPaged,
|
||||
preferences.dualPageRotateToFit,
|
||||
preferences.dualPageRotateToFitInvert,
|
||||
) {
|
||||
SimplePagedSettings(
|
||||
dualPageSplit = dualPageSplitPaged.get(),
|
||||
dualPageInvert = dualPageInvertPaged.get(),
|
||||
dualPageRotateToFit = dualPageRotateToFit.get(),
|
||||
dualPageRotateToFitInvert = dualPageRotateToFitInvert.get(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings only the webtoon viewers use.
|
||||
*/
|
||||
val simpleLongStrip = settings(
|
||||
preferences.webtoonSidePadding,
|
||||
preferences.dualPageSplitWebtoon,
|
||||
preferences.dualPageInvertWebtoon,
|
||||
preferences.dualPageRotateToFitWebtoon,
|
||||
preferences.dualPageRotateToFitInvertWebtoon,
|
||||
preferences.webtoonDoubleTapZoomEnabled,
|
||||
preferences.navigationModeWebtoon,
|
||||
preferences.webtoonNavInverted,
|
||||
) {
|
||||
SimpleLongStripSettings(
|
||||
page = SimpleLongStripSettings.Page(
|
||||
sidePadding = webtoonSidePadding.get(),
|
||||
dualPageSplit = dualPageSplitWebtoon.get(),
|
||||
dualPageInvert = dualPageInvertWebtoon.get(),
|
||||
dualPageRotateToFit = dualPageRotateToFitWebtoon.get(),
|
||||
dualPageRotateToFitInvert = dualPageRotateToFitInvertWebtoon.get(),
|
||||
),
|
||||
doubleTapZoom = webtoonDoubleTapZoomEnabled.get(),
|
||||
navigation = NavigationSettings(
|
||||
ReaderPreferences.NavigationMode.fromValue(navigationModeWebtoon.get()),
|
||||
webtoonNavInverted.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings only the paged WebGPU viewers use.
|
||||
*/
|
||||
val webgpuPaged = settings(
|
||||
preferences.dualPageView,
|
||||
preferences.transitionAnimation,
|
||||
preferences.transitionAnimationDual,
|
||||
preferences.cutoutMode,
|
||||
preferences.cutoutModeDual,
|
||||
preferences.disableDoubleTapGestures,
|
||||
preferences.imageFilters,
|
||||
preferences.tileFilters,
|
||||
) {
|
||||
WebGpuPagedSettings(
|
||||
page = WebGpuPagedSettings.Page(
|
||||
dualPageView = dualPageView.get(),
|
||||
transitionAnimation = transitionAnimation.get(),
|
||||
transitionAnimationDual = transitionAnimationDual.get(),
|
||||
cutoutMode = cutoutMode.get(),
|
||||
cutoutModeDual = cutoutModeDual.get(),
|
||||
),
|
||||
common = WebGpuSettings(
|
||||
doubleTapGesturesDisabled = disableDoubleTapGestures.get(),
|
||||
imageFilters = imageFilters.get(),
|
||||
tileFilters = tileFilters.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings only the long strip WebGPU viewer uses.
|
||||
*/
|
||||
val webgpuLongStrip = settings(
|
||||
preferences.continuousMinWidth,
|
||||
preferences.continuousGap,
|
||||
preferences.disableDoubleTapGestures,
|
||||
preferences.imageFilters,
|
||||
preferences.tileFilters,
|
||||
) {
|
||||
WebGpuLongStripSettings(
|
||||
page = WebGpuLongStripSettings.Page(
|
||||
continuousMinWidth = continuousMinWidth.get(),
|
||||
continuousGap = continuousGap.get(),
|
||||
),
|
||||
common = WebGpuSettings(
|
||||
doubleTapGesturesDisabled = disableDoubleTapGestures.get(),
|
||||
imageFilters = imageFilters.get(),
|
||||
tileFilters = tileFilters.get(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private val viewerMode = MutableStateFlow<ViewerMode?>(null)
|
||||
|
||||
/**
|
||||
* Tap zones of the open viewer. Null until a viewer is made.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val navigation: StateFlow<ViewerNavigation?> = viewerMode
|
||||
.flatMapLatest { viewerMode ->
|
||||
if (viewerMode == null) return@flatMapLatest flowOf(null)
|
||||
|
||||
val usesWebtoonZones = viewerMode.readingMode.type == ReadingMode.ViewerType.Webtoon &&
|
||||
!viewerMode.isWebGpu
|
||||
val settings = if (usesWebtoonZones) {
|
||||
simpleLongStrip.map { it.navigation }
|
||||
} else {
|
||||
paged.map { it.navigation }
|
||||
}
|
||||
val isReversed = viewerMode.readingMode == ReadingMode.RIGHT_TO_LEFT
|
||||
val defaultNavigation: (ReaderPreferences.TappingInvertMode, Boolean) -> ViewerNavigation =
|
||||
if (viewerMode.readingMode.direction == ReadingMode.Direction.Vertical) {
|
||||
::LNavigation
|
||||
} else {
|
||||
::RightAndLeftNavigation
|
||||
}
|
||||
settings.distinctUntilChanged().map { it.navigator(isReversed, defaultNavigation) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* Called when a viewer is made for [readingMode].
|
||||
*/
|
||||
fun setViewerReadingMode(readingMode: ReadingMode) {
|
||||
viewerMode.value = ViewerMode(readingMode, isWebGpu.get())
|
||||
}
|
||||
|
||||
fun setReadingMode(readingMode: ReadingMode) = readerViewModel.setMangaReadingMode(readingMode)
|
||||
|
||||
fun setOrientation(orientation: ReaderOrientation) = readerViewModel.setMangaOrientationType(orientation)
|
||||
|
||||
/**
|
||||
* Whether a viewer must show its tap zones because the reader is new to the user, which it is only once.
|
||||
*/
|
||||
fun takeNavigationOverlayForNewUser(): Boolean {
|
||||
val show = preferences.showNavigationOverlayNewUser.get()
|
||||
if (show) {
|
||||
preferences.showNavigationOverlayNewUser.set(false)
|
||||
}
|
||||
return show
|
||||
}
|
||||
|
||||
/**
|
||||
* A value [read] from [sources], kept current as any of them changes. Started eagerly, so a viewer reading it
|
||||
* before collecting gets what the preferences hold now.
|
||||
*/
|
||||
private fun <T> settings(vararg sources: Preference<*>, read: ReaderPreferences.() -> T): StateFlow<T> {
|
||||
return sources.map { it.changes() }
|
||||
.merge()
|
||||
.map { preferences.read() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, preferences.read())
|
||||
}
|
||||
|
||||
private data class ViewerMode(
|
||||
val readingMode: ReadingMode,
|
||||
val isWebGpu: Boolean,
|
||||
)
|
||||
val mangaFlow = readerState
|
||||
.map { it.manga }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
|
||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
|
||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
|
||||
@@ -77,16 +74,6 @@ enum class ReadingMode(
|
||||
}
|
||||
|
||||
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
|
||||
if (activity.appGraph.basePreferences.highQualityRenderer.get()) {
|
||||
return when (fromPreference(preference)) {
|
||||
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
|
||||
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
|
||||
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
|
||||
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
|
||||
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
|
||||
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
|
||||
}
|
||||
}
|
||||
return when (fromPreference(preference)) {
|
||||
LEFT_TO_RIGHT -> L2RPagerViewer(activity)
|
||||
RIGHT_TO_LEFT -> R2LPagerViewer(activity)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
/**
|
||||
* How a viewer shows a wide page.
|
||||
*/
|
||||
interface DualPageSettings {
|
||||
val dualPageSplit: Boolean
|
||||
val dualPageInvert: Boolean
|
||||
val dualPageRotateToFit: Boolean
|
||||
val dualPageRotateToFitInvert: Boolean
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
/**
|
||||
* Settings every long strip viewer uses.
|
||||
*/
|
||||
data class LongStripSettings(
|
||||
val cropBorders: Boolean,
|
||||
val zoomOutDisabled: Boolean,
|
||||
)
|
||||
@@ -1,89 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
|
||||
/**
|
||||
* The image of [page] as a pager shows it: a wide page rotated to fit or split in two, the half on the reading
|
||||
* side first, so [isLeftToRight] tells which. [onPageSplit] is told of the [InsertPage] for the second half.
|
||||
*/
|
||||
fun DualPageSettings.processPagedImage(
|
||||
page: ReaderPage,
|
||||
imageSource: BufferedSource,
|
||||
isLeftToRight: Boolean,
|
||||
onPageSplit: (InsertPage) -> Unit,
|
||||
): BufferedSource {
|
||||
if (dualPageRotateToFit) {
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (!dualPageSplit) {
|
||||
return imageSource
|
||||
}
|
||||
|
||||
if (page is InsertPage) {
|
||||
return splitInHalf(imageSource, page, isLeftToRight)
|
||||
}
|
||||
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (!isDoublePage) {
|
||||
return imageSource
|
||||
}
|
||||
|
||||
onPageSplit(InsertPage(page))
|
||||
|
||||
return splitInHalf(imageSource, page, isLeftToRight)
|
||||
}
|
||||
|
||||
/**
|
||||
* The image of a page as a strip shows it: a wide page rotated to fit, or split with its halves one above the other.
|
||||
*/
|
||||
fun DualPageSettings.processStripImage(imageSource: BufferedSource): BufferedSource {
|
||||
if (dualPageRotateToFit) {
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (dualPageSplit) {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (isDoublePage) {
|
||||
val upperSide = if (dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
|
||||
return ImageUtil.splitAndMerge(imageSource, upperSide)
|
||||
}
|
||||
}
|
||||
|
||||
return imageSource
|
||||
}
|
||||
|
||||
private fun DualPageSettings.rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
private fun DualPageSettings.splitInHalf(
|
||||
imageSource: BufferedSource,
|
||||
page: ReaderPage,
|
||||
isLeftToRight: Boolean,
|
||||
): BufferedSource {
|
||||
var side = when {
|
||||
isLeftToRight && page is InsertPage -> ImageUtil.Side.RIGHT
|
||||
!isLeftToRight && page is InsertPage -> ImageUtil.Side.LEFT
|
||||
isLeftToRight && page !is InsertPage -> ImageUtil.Side.LEFT
|
||||
else -> ImageUtil.Side.RIGHT
|
||||
}
|
||||
|
||||
if (dualPageInvert) {
|
||||
side = when (side) {
|
||||
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
|
||||
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
|
||||
}
|
||||
}
|
||||
|
||||
return ImageUtil.splitInHalf(imageSource, side)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
|
||||
/**
|
||||
* Settings every paged viewer uses.
|
||||
*/
|
||||
data class PagedSettings(
|
||||
val page: Page,
|
||||
val navigateToPan: Boolean,
|
||||
val navigation: NavigationSettings,
|
||||
) {
|
||||
|
||||
/**
|
||||
* What the pages are made with, so a change remakes them.
|
||||
*/
|
||||
data class Page(
|
||||
val imageScaleType: ReaderPreferences.ScaleType,
|
||||
val zoomStart: ReaderPreferences.ZoomStartType,
|
||||
val cropBorders: Boolean,
|
||||
val landscapeZoom: Boolean,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
|
||||
|
||||
/**
|
||||
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
|
||||
* the pager, but this class disables that behavior to allow clickable buttons.
|
||||
*/
|
||||
class ReaderButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.materialButtonStyle,
|
||||
) : MaterialButton(context, attrs, defStyleAttr) {
|
||||
|
||||
var viewer: PagerViewer? = null
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
viewer?.pager?.setGestureDetectorEnabled(false)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
viewer?.pager?.setGestureDetectorEnabled(true)
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.presentation.reader.ReaderError
|
||||
import eu.kanade.presentation.util.formattedMessage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
|
||||
/**
|
||||
* [ReaderError] for a [page] that failed with [error]: Retry asks its loader again, and an image from the web can
|
||||
* be opened in a WebView with the cookies of the source [sourceId].
|
||||
*/
|
||||
@Composable
|
||||
fun ReaderPageError(
|
||||
page: ReaderPage,
|
||||
error: Throwable?,
|
||||
sourceId: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imageUrl = page.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
|
||||
ReaderError(
|
||||
message = with(context) { error?.formattedMessage },
|
||||
onRetry = { page.chapter.pageLoader?.retryPage(page) },
|
||||
onOpenInWebView = imageUrl?.let {
|
||||
{ context.startActivity(WebViewActivity.newIntent(context, it, sourceId)) }
|
||||
},
|
||||
modifier = modifier,
|
||||
onButtonPressChange = onButtonPressChange,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [ReaderPageError] for the view-based viewers, filling the view, in the theme of its reader-themed [context].
|
||||
*/
|
||||
class ReaderPageErrorView(context: Context) : AbstractComposeView(context) {
|
||||
|
||||
private var data by mutableStateOf<Data?>(null)
|
||||
|
||||
fun bind(
|
||||
page: ReaderPage,
|
||||
error: Throwable?,
|
||||
sourceId: Long?,
|
||||
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
data = Data(page, error, sourceId, onButtonPressChange)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val data = data ?: return
|
||||
ReaderThemedContent(context) {
|
||||
ReaderPageError(
|
||||
page = data.page,
|
||||
error = data.error,
|
||||
sourceId = data.sourceId,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onButtonPressChange = data.onButtonPressChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Data(
|
||||
val page: ReaderPage,
|
||||
val error: Throwable?,
|
||||
val sourceId: Long?,
|
||||
val onButtonPressChange: ((pressed: Boolean) -> Unit)?,
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Animatable
|
||||
@@ -15,7 +13,6 @@ import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StyleRes
|
||||
@@ -44,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
|
||||
/**
|
||||
* A wrapper view for showing page image.
|
||||
@@ -63,30 +61,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
|
||||
private var pageView: View? = null
|
||||
|
||||
/**
|
||||
* The reader's grayscale and inverted colours for the page image alone, so the transition and error views
|
||||
* keep theirs - see [LocalPageColorFilter].
|
||||
*/
|
||||
var pageColorFilter: ColorFilter? = null
|
||||
set(value) {
|
||||
if (field == value) return
|
||||
field = value
|
||||
applyPageColorFilter()
|
||||
}
|
||||
|
||||
private fun applyPageColorFilter() {
|
||||
val filter = pageColorFilter
|
||||
when (val view = pageView) {
|
||||
is ImageView -> view.colorFilter = filter
|
||||
is WebtoonStripImageView -> view.colorFilter = filter
|
||||
// Drawn with its own paints: a layer takes the filter instead, as wide as the screen it fills.
|
||||
is SubsamplingScaleImageView -> view.setLayerType(
|
||||
if (filter == null) LAYER_TYPE_NONE else LAYER_TYPE_HARDWARE,
|
||||
filter?.let { Paint().apply { colorFilter = it } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var stripRequest: Disposable? = null
|
||||
|
||||
private var config: Config? = null
|
||||
@@ -267,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
removeView(pageView)
|
||||
|
||||
pageView = SubsamplingScaleImageView(context).apply {
|
||||
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
|
||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
|
||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||
setMinimumTileDpi(180)
|
||||
@@ -284,7 +259,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
setOnClickListener { this@ReaderPageImageView.onViewClicked() }
|
||||
}
|
||||
addView(pageView, MATCH_PARENT, MATCH_PARENT)
|
||||
applyPageColorFilter()
|
||||
}
|
||||
|
||||
private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
|
||||
@@ -328,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
isVisible = true
|
||||
}
|
||||
is BufferedSource -> {
|
||||
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
|
||||
setImage(ImageSource.inputStream(data.inputStream()))
|
||||
isVisible = true
|
||||
}
|
||||
@@ -343,7 +318,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
|
||||
pageView = WebtoonStripImageView(context)
|
||||
addView(pageView, MATCH_PARENT, WRAP_CONTENT)
|
||||
applyPageColorFilter()
|
||||
}
|
||||
|
||||
private fun setStripImage(data: BufferedSource, config: Config) {
|
||||
@@ -420,7 +394,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
addView(pageView, MATCH_PARENT, MATCH_PARENT)
|
||||
applyPageColorFilter()
|
||||
}
|
||||
|
||||
private fun setAnimatedImage(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
|
||||
/**
|
||||
* [content] in the app's theme under the reader's own night mode, from [readerThemedContext].
|
||||
*
|
||||
* Pages sit on the reader background, so a light app over a black reader would otherwise draw dark text on black.
|
||||
* [TachiyomiTheme] reads that from the configuration, which the view-based viewers got by inflating their views
|
||||
* from a reader-themed context.
|
||||
*/
|
||||
@Composable
|
||||
fun ReaderThemedContent(readerThemedContext: Context, content: @Composable () -> Unit) {
|
||||
CompositionLocalProvider(
|
||||
LocalContext provides readerThemedContext,
|
||||
LocalConfiguration provides readerThemedContext.resources.configuration,
|
||||
) {
|
||||
TachiyomiTheme(content = content)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import eu.kanade.presentation.reader.ChapterTransition
|
||||
import eu.kanade.presentation.reader.reportPress
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun ReaderTransitionPage(
|
||||
transition: ChapterTransition,
|
||||
downloadState: TransitionDownloadState?,
|
||||
onRetry: (ReaderChapter) -> Unit,
|
||||
onButtonPressChange: (pressed: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
statusPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (downloadState != null) {
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
ChapterTransition(
|
||||
transition,
|
||||
downloadState.currChapterDownloaded,
|
||||
downloadState.goingToChapterDownloaded,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val toChapter = transition.to ?: return@Column
|
||||
val state by toChapter.stateFlow.collectAsState()
|
||||
when (val state = state) {
|
||||
is ReaderChapter.State.Loading -> Column(
|
||||
modifier = Modifier.padding(statusPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringResource(MR.strings.transition_pages_loading),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
is ReaderChapter.State.Error -> Column(
|
||||
modifier = Modifier.padding(statusPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(MR.strings.transition_pages_error, state.error.message ?: ""),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = { onRetry(toChapter) },
|
||||
modifier = Modifier.reportPress(onButtonPressChange),
|
||||
) {
|
||||
Text(stringResource(MR.strings.action_retry))
|
||||
}
|
||||
}
|
||||
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,58 +2,91 @@ package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import eu.kanade.presentation.reader.ChapterTransition
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.source.local.isLocal
|
||||
|
||||
/**
|
||||
* [ReaderTransitionPage] for the view-based viewers, in the theme of its reader-themed [context].
|
||||
*/
|
||||
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||
AbstractComposeView(context, attrs) {
|
||||
|
||||
private var data: Data? by mutableStateOf(null)
|
||||
|
||||
init {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
fun bind(
|
||||
transition: ChapterTransition,
|
||||
downloadState: TransitionDownloadState?,
|
||||
onRetry: (ReaderChapter) -> Unit,
|
||||
onButtonPressChange: (pressed: Boolean) -> Unit = {},
|
||||
statusPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
data = Data(transition, downloadState, onRetry, onButtonPressChange, statusPadding)
|
||||
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
|
||||
data = if (manga != null && source != null) {
|
||||
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
|
||||
Data(
|
||||
transition = transition,
|
||||
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
|
||||
goingToChapterDownloaded = manga.isLocal() ||
|
||||
goingToChapter?.let {
|
||||
downloadManager.isChapterDownloaded(
|
||||
chapterName = it.name,
|
||||
chapterScanlator = it.scanlator,
|
||||
chapterUrl = it.url,
|
||||
mangaTitle = manga.title,
|
||||
sourceId = manga.source,
|
||||
)
|
||||
} ?: false,
|
||||
isGoingToChapterOnDisk = goingToChapter?.let {
|
||||
{
|
||||
downloadManager.isChapterDownloadedOnDisk(
|
||||
chapterName = it.name,
|
||||
chapterScanlator = it.scanlator,
|
||||
chapterUrl = it.url,
|
||||
mangaTitle = manga.title,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val data = data ?: return
|
||||
LaunchedEffect(data.downloadState) { data.downloadState?.checkDisk() }
|
||||
ReaderThemedContent(context) {
|
||||
ReaderTransitionPage(
|
||||
transition = data.transition,
|
||||
downloadState = data.downloadState,
|
||||
onRetry = data.onRetry,
|
||||
onButtonPressChange = data.onButtonPressChange,
|
||||
statusPadding = data.statusPadding,
|
||||
)
|
||||
data?.let {
|
||||
val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
|
||||
it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
|
||||
}
|
||||
TachiyomiTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
ChapterTransition(
|
||||
transition = it.transition,
|
||||
currChapterDownloaded = it.currChapterDownloaded,
|
||||
goingToChapterDownloaded = goingToChapterDownloaded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Data(
|
||||
val transition: ChapterTransition,
|
||||
val downloadState: TransitionDownloadState?,
|
||||
val onRetry: (ReaderChapter) -> Unit,
|
||||
val onButtonPressChange: (pressed: Boolean) -> Unit,
|
||||
val statusPadding: PaddingValues,
|
||||
val currChapterDownloaded: Boolean,
|
||||
val goingToChapterDownloaded: Boolean,
|
||||
val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.source.local.isLocal
|
||||
|
||||
class TransitionDownloadState(
|
||||
val currChapterDownloaded: Boolean,
|
||||
goingToChapterDownloaded: Boolean,
|
||||
private val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
|
||||
) {
|
||||
var goingToChapterDownloaded by mutableStateOf(goingToChapterDownloaded)
|
||||
private set
|
||||
|
||||
suspend fun checkDisk() {
|
||||
isGoingToChapterOnDisk?.let { goingToChapterDownloaded = it() }
|
||||
}
|
||||
}
|
||||
|
||||
fun DownloadManager.transitionDownloadState(
|
||||
transition: ChapterTransition,
|
||||
manga: Manga?,
|
||||
source: Source?,
|
||||
): TransitionDownloadState? {
|
||||
if (manga == null || source == null) return null
|
||||
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
|
||||
return TransitionDownloadState(
|
||||
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
|
||||
goingToChapterDownloaded = manga.isLocal() ||
|
||||
goingToChapter?.let {
|
||||
isChapterDownloaded(
|
||||
chapterName = it.name,
|
||||
chapterScanlator = it.scanlator,
|
||||
chapterUrl = it.url,
|
||||
mangaTitle = manga.title,
|
||||
sourceId = manga.source,
|
||||
)
|
||||
} ?: false,
|
||||
isGoingToChapterOnDisk = goingToChapter?.let {
|
||||
{
|
||||
isChapterDownloadedOnDisk(
|
||||
chapterName = it.name,
|
||||
chapterScanlator = it.scanlator,
|
||||
chapterUrl = it.url,
|
||||
mangaTitle = manga.title,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
|
||||
@@ -16,10 +12,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
interface Viewer {
|
||||
|
||||
/**
|
||||
* Draws this viewer.
|
||||
* Returns the view this viewer uses.
|
||||
*/
|
||||
@Composable
|
||||
fun Content(modifier: Modifier)
|
||||
fun getView(): View
|
||||
|
||||
/**
|
||||
* Destroys this viewer. Called when leaving the reader or swapping viewers.
|
||||
@@ -37,37 +32,14 @@ interface Viewer {
|
||||
fun moveToPage(page: ReaderPage)
|
||||
|
||||
/**
|
||||
* Moves forward through the pages.
|
||||
* Called from the containing activity when a key [event] is received. It should return true
|
||||
* if the event was handled, false otherwise.
|
||||
*/
|
||||
fun moveForward()
|
||||
fun handleKeyEvent(event: KeyEvent): Boolean
|
||||
|
||||
/**
|
||||
* Moves backward through the pages.
|
||||
* Called from the containing activity when a generic motion [event] is received. It should
|
||||
* return true if the event was handled, false otherwise.
|
||||
*/
|
||||
fun moveBackward()
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's grayscale and inverted colours, for page images only: transition, loading and error pages keep
|
||||
* theirs. Null when both are off.
|
||||
*/
|
||||
val LocalPageColorFilter = compositionLocalOf<ColorFilter?> { null }
|
||||
|
||||
/**
|
||||
* Turns pages with the mouse wheel instead of letting the content scroll.
|
||||
*/
|
||||
fun Modifier.mouseWheelPaging(viewer: Viewer): Modifier = pointerInput(viewer) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
if (event.type != PointerEventType.Scroll) continue
|
||||
val delta = event.changes.first().scrollDelta.y
|
||||
when {
|
||||
delta > 0 -> viewer.moveForward()
|
||||
delta < 0 -> viewer.moveBackward()
|
||||
else -> continue
|
||||
}
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
fun handleGenericMotionEvent(event: MotionEvent): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
|
||||
/**
|
||||
* Common configuration for all viewers.
|
||||
*/
|
||||
abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val scope: CoroutineScope) {
|
||||
|
||||
var imagePropertyChangedListener: (() -> Unit)? = null
|
||||
|
||||
var navigationModeChangedListener: (() -> Unit)? = null
|
||||
|
||||
var tappingInverted = ReaderPreferences.TappingInvertMode.NONE
|
||||
var longTapEnabled = true
|
||||
var usePageTransitions = false
|
||||
var doubleTapAnimDuration = 500
|
||||
var volumeKeysEnabled = false
|
||||
var volumeKeysInverted = false
|
||||
var alwaysShowChapterTransition = true
|
||||
var navigationMode = 0
|
||||
protected set
|
||||
|
||||
var forceNavigationOverlay = false
|
||||
|
||||
var navigationOverlayOnStart = false
|
||||
|
||||
var dualPageSplit = false
|
||||
protected set
|
||||
|
||||
var dualPageInvert = false
|
||||
protected set
|
||||
|
||||
var dualPageRotateToFit = false
|
||||
protected set
|
||||
|
||||
var dualPageRotateToFitInvert = false
|
||||
protected set
|
||||
|
||||
abstract var navigator: ViewerNavigation
|
||||
protected set
|
||||
|
||||
init {
|
||||
readerPreferences.readWithLongTap
|
||||
.register({ longTapEnabled = it })
|
||||
|
||||
readerPreferences.pageTransitions
|
||||
.register({ usePageTransitions = it })
|
||||
|
||||
readerPreferences.doubleTapAnimSpeed
|
||||
.register({ doubleTapAnimDuration = it })
|
||||
|
||||
readerPreferences.readWithVolumeKeys
|
||||
.register({ volumeKeysEnabled = it })
|
||||
|
||||
readerPreferences.readWithVolumeKeysInverted
|
||||
.register({ volumeKeysInverted = it })
|
||||
|
||||
readerPreferences.alwaysShowChapterTransition
|
||||
.register({ alwaysShowChapterTransition = it })
|
||||
|
||||
forceNavigationOverlay = readerPreferences.showNavigationOverlayNewUser.get()
|
||||
if (forceNavigationOverlay) {
|
||||
readerPreferences.showNavigationOverlayNewUser.set(false)
|
||||
}
|
||||
|
||||
readerPreferences.showNavigationOverlayOnStart
|
||||
.register({ navigationOverlayOnStart = it })
|
||||
}
|
||||
|
||||
protected abstract fun defaultNavigation(): ViewerNavigation
|
||||
|
||||
abstract fun updateNavigation(navigationMode: Int)
|
||||
|
||||
fun <T> Preference<T>.register(
|
||||
valueAssignment: (T) -> Unit,
|
||||
onChanged: (T) -> Unit = {},
|
||||
) {
|
||||
changes()
|
||||
.onEach { valueAssignment(it) }
|
||||
.distinctUntilChanged()
|
||||
.onEach { onChanged(it) }
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
|
||||
/**
|
||||
* The items of a viewer for [chapters], top to bottom: the previous chapter's pages, its transition, [currentPages],
|
||||
* then the next transition and chapter's pages, so moving between chapters is seamless. A transition is left out
|
||||
* when its chapter is loaded and adjacent, unless [forceTransition].
|
||||
*/
|
||||
fun viewerItems(
|
||||
chapters: ViewerChapters,
|
||||
forceTransition: Boolean,
|
||||
currentPages: List<ReaderPage>? = chapters.currChapter.pages,
|
||||
): MutableList<Any> {
|
||||
val items = mutableListOf<Any>()
|
||||
|
||||
// Forces chapter transition if there is missing chapters
|
||||
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
|
||||
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
||||
|
||||
// Add previous chapter pages and transition
|
||||
chapters.prevChapter?.pages?.let(items::addAll)
|
||||
|
||||
// Skip transition page if the chapter is loaded & current page is not a transition page
|
||||
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
items.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
}
|
||||
|
||||
// Add current chapter.
|
||||
currentPages?.let(items::addAll)
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
items.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
}
|
||||
|
||||
chapters.nextChapter?.pages?.let(items::addAll)
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -1,75 +1,54 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.graphics.Color
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.lang.invert
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* Tap zones of a viewer. [isReversed] is set for right to left books, where the right side of the
|
||||
* screen goes back.
|
||||
*/
|
||||
abstract class ViewerNavigation(
|
||||
val invertMode: ReaderPreferences.TappingInvertMode,
|
||||
val isReversed: Boolean,
|
||||
) {
|
||||
abstract class ViewerNavigation {
|
||||
|
||||
enum class Action { MENU, FORWARD, BACKWARD }
|
||||
|
||||
sealed class NavigationRegion(val nameRes: StringResource, val color: Color) {
|
||||
data object MENU : NavigationRegion(MR.strings.action_menu, Color(0xCC95818D))
|
||||
data object PREV : NavigationRegion(MR.strings.nav_zone_prev, Color(0xCCFF7733))
|
||||
data object NEXT : NavigationRegion(MR.strings.nav_zone_next, Color(0xCC84E296))
|
||||
data object LEFT : NavigationRegion(MR.strings.nav_zone_left, Color(0xCC7D1128))
|
||||
data object RIGHT : NavigationRegion(MR.strings.nav_zone_right, Color(0xCCA6CFD5))
|
||||
sealed class NavigationRegion(val nameRes: StringResource, val color: Int) {
|
||||
data object MENU : NavigationRegion(MR.strings.action_menu, Color.argb(0xCC, 0x95, 0x81, 0x8D))
|
||||
data object PREV : NavigationRegion(MR.strings.nav_zone_prev, Color.argb(0xCC, 0xFF, 0x77, 0x33))
|
||||
data object NEXT : NavigationRegion(MR.strings.nav_zone_next, Color.argb(0xCC, 0x84, 0xE2, 0x96))
|
||||
data object LEFT : NavigationRegion(MR.strings.nav_zone_left, Color.argb(0xCC, 0x7D, 0x11, 0x28))
|
||||
data object RIGHT : NavigationRegion(MR.strings.nav_zone_right, Color.argb(0xCC, 0xA6, 0xCF, 0xD5))
|
||||
}
|
||||
|
||||
data class Region(
|
||||
val rect: Rect,
|
||||
val rectF: RectF,
|
||||
val type: NavigationRegion,
|
||||
) {
|
||||
fun invert(invertMode: ReaderPreferences.TappingInvertMode): Region {
|
||||
val horizontal = invertMode.shouldInvertHorizontal
|
||||
val vertical = invertMode.shouldInvertVertical
|
||||
if (!horizontal && !vertical) return this
|
||||
return copy(
|
||||
rect = Rect(
|
||||
left = if (horizontal) 1f - rect.right else rect.left,
|
||||
top = if (vertical) 1f - rect.bottom else rect.top,
|
||||
right = if (horizontal) 1f - rect.left else rect.right,
|
||||
bottom = if (vertical) 1f - rect.top else rect.bottom,
|
||||
),
|
||||
if (invertMode == ReaderPreferences.TappingInvertMode.NONE) return this
|
||||
return this.copy(
|
||||
rectF = this.rectF.invert(invertMode),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val regionList: List<Region>
|
||||
private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
|
||||
|
||||
var invertMode: ReaderPreferences.TappingInvertMode = ReaderPreferences.TappingInvertMode.NONE
|
||||
|
||||
protected abstract var regionList: List<Region>
|
||||
|
||||
/** Returns regions with applied inversion. */
|
||||
fun getRegions(): List<Region> {
|
||||
return regionList.map { it.invert(invertMode) }
|
||||
}
|
||||
|
||||
fun getAction(pos: Offset): Action {
|
||||
val region = getRegions().find { pos in it.rect } ?: return Action.MENU
|
||||
return actionOf(region.type)
|
||||
fun getAction(pos: PointF): NavigationRegion {
|
||||
val x = pos.x
|
||||
val y = pos.y
|
||||
val region = getRegions().find { it.rectF.contains(x, y) }
|
||||
return when {
|
||||
region != null -> region.type
|
||||
constantMenuRegion.contains(x, y) -> NavigationRegion.MENU
|
||||
else -> NavigationRegion.MENU
|
||||
}
|
||||
}
|
||||
|
||||
fun actionOf(region: NavigationRegion): Action = when (region) {
|
||||
NavigationRegion.MENU -> Action.MENU
|
||||
NavigationRegion.NEXT -> Action.FORWARD
|
||||
NavigationRegion.PREV -> Action.BACKWARD
|
||||
NavigationRegion.RIGHT -> if (isReversed) Action.BACKWARD else Action.FORWARD
|
||||
NavigationRegion.LEFT -> if (isReversed) Action.FORWARD else Action.BACKWARD
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is ViewerNavigation &&
|
||||
other.javaClass == javaClass &&
|
||||
other.invertMode == invertMode &&
|
||||
other.isReversed == isReversed
|
||||
|
||||
override fun hashCode(): Int = (31 * javaClass.hashCode() + invertMode.hashCode()) * 31 + isReversed.hashCode()
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A viewer's tap zones, as the preferences store them.
|
||||
*/
|
||||
data class NavigationSettings(
|
||||
val mode: ReaderPreferences.NavigationMode,
|
||||
val inverted: ReaderPreferences.TappingInvertMode,
|
||||
) {
|
||||
|
||||
fun navigator(
|
||||
isReversed: Boolean,
|
||||
default: (ReaderPreferences.TappingInvertMode, Boolean) -> ViewerNavigation,
|
||||
): ViewerNavigation {
|
||||
return when (mode) {
|
||||
ReaderPreferences.NavigationMode.DEFAULT -> default(inverted, isReversed)
|
||||
ReaderPreferences.NavigationMode.L_SHAPED -> LNavigation(inverted, isReversed)
|
||||
ReaderPreferences.NavigationMode.KINDLISH -> KindlishNavigation(inverted, isReversed)
|
||||
ReaderPreferences.NavigationMode.EDGE -> EdgeNavigation(inverted, isReversed)
|
||||
ReaderPreferences.NavigationMode.RIGHT_AND_LEFT -> RightAndLeftNavigation(inverted, isReversed)
|
||||
ReaderPreferences.NavigationMode.DISABLED -> DisabledNavigation(inverted, isReversed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a page starts zoomed for this setting, where [auto] is what automatic means for the viewer.
|
||||
*/
|
||||
fun ReaderPreferences.ZoomStartType.position(auto: ZoomStartPosition): ZoomStartPosition {
|
||||
return when (this) {
|
||||
ReaderPreferences.ZoomStartType.AUTOMATIC -> auto
|
||||
ReaderPreferences.ZoomStartType.LEFT -> ZoomStartPosition.LEFT
|
||||
ReaderPreferences.ZoomStartType.RIGHT -> ZoomStartPosition.RIGHT
|
||||
ReaderPreferences.ZoomStartType.CENTER -> ZoomStartPosition.CENTER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [action] in [scope] with each value and the one before it, null for the first, so a viewer applies a change
|
||||
* in one place by comparing the parts it reacts to.
|
||||
*/
|
||||
fun <T : Any> Flow<T>.collectChanges(scope: CoroutineScope, action: (old: T?, new: T) -> Unit) {
|
||||
scope.launch {
|
||||
var old: T? = null
|
||||
collect {
|
||||
action(old, it)
|
||||
old = it
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -1,6 +1,5 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -13,10 +12,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | M | M | M | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class DisabledNavigation(
|
||||
invertMode: TappingInvertMode,
|
||||
isReversed: Boolean,
|
||||
) : ViewerNavigation(invertMode, isReversed) {
|
||||
class DisabledNavigation : ViewerNavigation() {
|
||||
|
||||
override val regionList: List<Region> = emptyList()
|
||||
override var regionList: List<Region> = emptyList()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -14,19 +13,19 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | P | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class EdgeNavigation(invertMode: TappingInvertMode, isReversed: Boolean) : ViewerNavigation(invertMode, isReversed) {
|
||||
class EdgeNavigation : ViewerNavigation() {
|
||||
|
||||
override val regionList: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rect = Rect(0f, 0f, 0.33f, 1f),
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0.33f, 0.66f, 0.66f, 1f),
|
||||
rectF = RectF(0.33f, 0.66f, 0.66f, 1f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0.66f, 0f, 1f, 1f),
|
||||
rectF = RectF(0.66f, 0f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-9
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -14,18 +13,15 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | P | N | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
class KindlishNavigation(
|
||||
invertMode: TappingInvertMode,
|
||||
isReversed: Boolean,
|
||||
) : ViewerNavigation(invertMode, isReversed) {
|
||||
class KindlishNavigation : ViewerNavigation() {
|
||||
|
||||
override val regionList: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rect = Rect(0.33f, 0.33f, 1f, 1f),
|
||||
rectF = RectF(0.33f, 0.33f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0f, 0.33f, 0.33f, 1f),
|
||||
rectF = RectF(0f, 0.33f, 0.33f, 1f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -14,23 +13,23 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | N | N | N: Next
|
||||
* +---+---+---+
|
||||
*/
|
||||
open class LNavigation(invertMode: TappingInvertMode, isReversed: Boolean) : ViewerNavigation(invertMode, isReversed) {
|
||||
open class LNavigation : ViewerNavigation() {
|
||||
|
||||
override val regionList: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rect = Rect(0f, 0.33f, 0.33f, 0.66f),
|
||||
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0f, 0f, 1f, 0.33f),
|
||||
rectF = RectF(0f, 0f, 1f, 0.33f),
|
||||
type = NavigationRegion.PREV,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0.66f, 0.33f, 1f, 0.66f),
|
||||
rectF = RectF(0.66f, 0.33f, 1f, 0.66f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0f, 0.66f, 1f, 1f),
|
||||
rectF = RectF(0f, 0.66f, 1f, 1f),
|
||||
type = NavigationRegion.NEXT,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-9
@@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TappingInvertMode
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
/**
|
||||
@@ -14,18 +13,15 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
* | N | M | P | N: Move Left
|
||||
* +---+---+---+
|
||||
*/
|
||||
class RightAndLeftNavigation(
|
||||
invertMode: TappingInvertMode,
|
||||
isReversed: Boolean,
|
||||
) : ViewerNavigation(invertMode, isReversed) {
|
||||
class RightAndLeftNavigation : ViewerNavigation() {
|
||||
|
||||
override val regionList: List<Region> = listOf(
|
||||
override var regionList: List<Region> = listOf(
|
||||
Region(
|
||||
rect = Rect(0f, 0f, 0.33f, 1f),
|
||||
rectF = RectF(0f, 0f, 0.33f, 1f),
|
||||
type = NavigationRegion.LEFT,
|
||||
),
|
||||
Region(
|
||||
rect = Rect(0.66f, 0f, 1f, 1f),
|
||||
rectF = RectF(0.66f, 0f, 1f, 1f),
|
||||
type = NavigationRegion.RIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
/**
|
||||
* Configuration used by pager viewers.
|
||||
*/
|
||||
class PagerConfig(
|
||||
private val viewer: PagerViewer,
|
||||
scope: CoroutineScope,
|
||||
readerPreferences: ReaderPreferences,
|
||||
) : ViewerConfig(readerPreferences, scope) {
|
||||
|
||||
var theme = readerPreferences.readerTheme.get()
|
||||
private set
|
||||
|
||||
var automaticBackground = false
|
||||
private set
|
||||
|
||||
var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
var imageScaleType = 1
|
||||
private set
|
||||
|
||||
var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
private set
|
||||
|
||||
var imageCropBorders = false
|
||||
private set
|
||||
|
||||
var navigateToPan = false
|
||||
private set
|
||||
|
||||
var landscapeZoom = false
|
||||
private set
|
||||
|
||||
init {
|
||||
readerPreferences.readerTheme
|
||||
.register(
|
||||
{
|
||||
theme = it
|
||||
automaticBackground = it == 3
|
||||
},
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.imageScaleType
|
||||
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.zoomStart
|
||||
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.cropBorders
|
||||
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.navigateToPan
|
||||
.register({ navigateToPan = it })
|
||||
|
||||
readerPreferences.landscapeZoom
|
||||
.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.navigationModePager
|
||||
.register({ navigationMode = it }, { updateNavigation(navigationMode) })
|
||||
|
||||
readerPreferences.pagerNavInverted
|
||||
.register({ tappingInverted = it }, { navigator.invertMode = it })
|
||||
readerPreferences.pagerNavInverted.changes()
|
||||
.drop(1)
|
||||
.onEach { navigationModeChangedListener?.invoke() }
|
||||
.launchIn(scope)
|
||||
|
||||
readerPreferences.dualPageSplitPaged
|
||||
.register(
|
||||
{ dualPageSplit = it },
|
||||
{
|
||||
imagePropertyChangedListener?.invoke()
|
||||
dualPageSplitChangedListener?.invoke(it)
|
||||
},
|
||||
)
|
||||
|
||||
readerPreferences.dualPageInvertPaged
|
||||
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.dualPageRotateToFit
|
||||
.register(
|
||||
{ dualPageRotateToFit = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.dualPageRotateToFitInvert
|
||||
.register(
|
||||
{ dualPageRotateToFitInvert = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun zoomTypeFromPreference(value: Int) {
|
||||
imageZoomType = when (value) {
|
||||
// Auto
|
||||
1 -> when (viewer) {
|
||||
is L2RPagerViewer -> ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
is R2LPagerViewer -> ReaderPageImageView.ZoomStartPosition.RIGHT
|
||||
else -> ReaderPageImageView.ZoomStartPosition.CENTER
|
||||
}
|
||||
// Left
|
||||
2 -> ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
// Right
|
||||
3 -> ReaderPageImageView.ZoomStartPosition.RIGHT
|
||||
// Center
|
||||
else -> ReaderPageImageView.ZoomStartPosition.CENTER
|
||||
}
|
||||
}
|
||||
|
||||
override var navigator: ViewerNavigation = defaultNavigation()
|
||||
set(value) {
|
||||
field = value.also { it.invertMode = this.tappingInverted }
|
||||
}
|
||||
|
||||
override fun defaultNavigation(): ViewerNavigation {
|
||||
return when (viewer) {
|
||||
is VerticalPagerViewer -> LNavigation()
|
||||
else -> RightAndLeftNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateNavigation(navigationMode: Int) {
|
||||
navigator = when (navigationMode) {
|
||||
0 -> defaultNavigation()
|
||||
1 -> LNavigation()
|
||||
2 -> KindlishNavigation()
|
||||
3 -> EdgeNavigation()
|
||||
4 -> RightAndLeftNavigation()
|
||||
5 -> DisabledNavigation()
|
||||
else -> defaultNavigation()
|
||||
}
|
||||
navigationModeChangedListener?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
|
||||
|
||||
/**
|
||||
* Items of a pager, where [ViewerChapters] updates are posted, inverted when the pager [isReversed] reads right to
|
||||
* left. Wide pages split in two get an [InsertPage] for their second half.
|
||||
*/
|
||||
class PagerItems(private val isReversed: Boolean) {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
var items: List<Any> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/**
|
||||
* Holds preprocessed items so they don't get removed when changing chapter
|
||||
*/
|
||||
private var preprocessed: List<InsertPage> = emptyList()
|
||||
|
||||
var nextTransition: ChapterTransition.Next? = null
|
||||
private set
|
||||
|
||||
var currentChapter: ReaderChapter? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Updates the items with the given [chapters] while the pager is at [currentPosition], and returns where it must
|
||||
* go next.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, currentPosition: Int): Update {
|
||||
var insertPageLastPage: InsertPage? = null
|
||||
|
||||
val currPages = chapters.currChapter.pages?.toMutableList()?.also { pages ->
|
||||
val lastPage = pages.last()
|
||||
|
||||
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
|
||||
// placed by their index, so only this chapter's belong here.
|
||||
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
|
||||
.sortedByDescending { it.index }
|
||||
.forEach { page ->
|
||||
if (lastPage.index == page.index) {
|
||||
insertPageLastPage = page
|
||||
}
|
||||
pages.add(page.index + 1, page)
|
||||
}
|
||||
}
|
||||
|
||||
val newItems = viewerItems(chapters, forceTransition, currPages)
|
||||
currentChapter = chapters.currChapter
|
||||
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
|
||||
|
||||
val currentItem = items.getOrNull(currentPosition)
|
||||
|
||||
if (isReversed) {
|
||||
newItems.reverse()
|
||||
}
|
||||
|
||||
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
|
||||
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
|
||||
items = newItems
|
||||
|
||||
// The pager only follows the pages it has laid out, so before its first layout it would stay at the same
|
||||
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
|
||||
// chapter
|
||||
val position = items.indexOf(currentItem).takeIf { it != -1 && it != currentPosition }
|
||||
return Update(position, insertPageLastPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [newPage], the second half of [currentPage], and returns whether the items changed.
|
||||
*/
|
||||
fun onPageSplit(currentPage: Any?, newPage: InsertPage): Boolean {
|
||||
if (currentPage !is ReaderPage) return false
|
||||
|
||||
val currentIndex = items.indexOf(currentPage)
|
||||
|
||||
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
|
||||
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
|
||||
val chapterId = newPage.chapter.chapter.id
|
||||
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
|
||||
preprocessed = preprocessed + newPage
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val placeAtIndex = if (isReversed) currentIndex else currentIndex + 1
|
||||
|
||||
// It will enter a endless cycle of insert pages
|
||||
if (isReversed && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same here it will enter a endless cycle of insert pages
|
||||
if (items[placeAtIndex] is InsertPage) {
|
||||
return false
|
||||
}
|
||||
|
||||
items = items.toMutableList().apply { add(placeAtIndex, newPage) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun cleanupPageSplit() {
|
||||
items = items.filterNot { it is InsertPage }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the pager goes after the items change: to [position] to stay on the same item, then to [insertPage] at
|
||||
* the end of the chapter, which it would skip otherwise.
|
||||
*/
|
||||
class Update(val position: Int?, val insertPage: InsertPage?)
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.view.isVisible
|
||||
import eu.kanade.presentation.util.formattedMessage
|
||||
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.processPagedImage
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -18,8 +22,11 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* View of the ViewPager that contains a page of a chapter.
|
||||
@@ -45,7 +52,7 @@ class PagerPageHolder(
|
||||
/**
|
||||
* Error layout to show when the image fails to load.
|
||||
*/
|
||||
private var errorLayout: ReaderPageErrorView? = null
|
||||
private var errorLayout: ReaderErrorBinding? = null
|
||||
|
||||
private val scope = MainScope()
|
||||
|
||||
@@ -55,7 +62,6 @@ class PagerPageHolder(
|
||||
private var loadJob: Job? = null
|
||||
|
||||
init {
|
||||
pageColorFilter = viewer.pageColorFilter
|
||||
loadJob = scope.launch { loadPageAndProcessStatus() }
|
||||
}
|
||||
|
||||
@@ -146,11 +152,9 @@ class PagerPageHolder(
|
||||
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
|
||||
// Splitting, merging and sampling the background decode the image, which is CPU work
|
||||
val (source, isAnimated, background) = withContext(Dispatchers.Default) {
|
||||
val source = viewer.simplePaged.processPagedImage(item, buffer, viewer is L2RPagerViewer) {
|
||||
viewer.onPageSplit(item, it)
|
||||
}
|
||||
val source = process(item, buffer)
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||
val background = if (!isAnimated && viewer.general.automaticBackground) {
|
||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||
ImageUtil.chooseBackground(context, source.peek().inputStream())
|
||||
} else {
|
||||
null
|
||||
@@ -162,11 +166,11 @@ class PagerPageHolder(
|
||||
source,
|
||||
isAnimated,
|
||||
Config(
|
||||
zoomDuration = viewer.general.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.paged.page.imageScaleType.value,
|
||||
cropBorders = viewer.paged.page.cropBorders,
|
||||
zoomStartPosition = viewer.imageZoomType,
|
||||
landscapeZoom = viewer.paged.page.landscapeZoom,
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.config.imageScaleType,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
zoomStartPosition = viewer.config.imageZoomType,
|
||||
landscapeZoom = viewer.config.landscapeZoom,
|
||||
),
|
||||
)
|
||||
if (!isAnimated) {
|
||||
@@ -182,6 +186,63 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (!viewer.config.dualPageSplit) {
|
||||
return imageSource
|
||||
}
|
||||
|
||||
if (page is InsertPage) {
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (!isDoublePage) {
|
||||
return imageSource
|
||||
}
|
||||
|
||||
onPageSplit(page)
|
||||
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
|
||||
var side = when {
|
||||
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
|
||||
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
|
||||
viewer is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.LEFT
|
||||
viewer !is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.RIGHT
|
||||
else -> error("We should choose a side!")
|
||||
}
|
||||
|
||||
if (viewer.config.dualPageInvert) {
|
||||
side = when (side) {
|
||||
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
|
||||
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
|
||||
}
|
||||
}
|
||||
|
||||
return ImageUtil.splitInHalf(imageSource, side)
|
||||
}
|
||||
|
||||
private fun onPageSplit(page: ReaderPage) {
|
||||
val newPage = InsertPage(page)
|
||||
viewer.onPageSplit(page, newPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page has an error.
|
||||
*/
|
||||
@@ -211,24 +272,41 @@ class PagerPageHolder(
|
||||
viewer.activity.hideMenu()
|
||||
}
|
||||
|
||||
private fun showErrorLayout(error: Throwable?) {
|
||||
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
|
||||
addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
errorLayout = it
|
||||
private fun showErrorLayout(error: Throwable?): ReaderErrorBinding {
|
||||
if (errorLayout == null) {
|
||||
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
errorLayout?.actionRetry?.viewer = viewer
|
||||
errorLayout?.actionRetry?.setOnClickListener {
|
||||
page.chapter.pageLoader?.retryPage(page)
|
||||
}
|
||||
}
|
||||
errorLayout.bind(
|
||||
page = page,
|
||||
error = error,
|
||||
sourceId = viewer.activity.viewModel.manga?.source,
|
||||
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
|
||||
)
|
||||
|
||||
val imageUrl = page.imageUrl
|
||||
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
|
||||
if (imageUrl != null) {
|
||||
if (imageUrl.startsWith("http", true)) {
|
||||
errorLayout?.actionOpenInWebView?.viewer = viewer
|
||||
errorLayout?.actionOpenInWebView?.setOnClickListener {
|
||||
val sourceId = viewer.activity.viewModel.manga?.source
|
||||
|
||||
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
|
||||
?: context.stringResource(MR.strings.decode_image_error)
|
||||
|
||||
errorLayout?.root?.isVisible = true
|
||||
return errorLayout!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the decode error layout from the holder, if found.
|
||||
*/
|
||||
private fun removeErrorLayout() {
|
||||
errorLayout?.let(::removeView)
|
||||
errorLayout?.root?.isVisible = false
|
||||
errorLayout = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
/**
|
||||
* The item a pager of [items] is on, shared by the view and Compose pagers. It tells [activity] of each new page,
|
||||
* preloads the chapters around it, and gives [onPageHolderSelected] the page along with whether it was reached moving
|
||||
* forward. Chapters set while the pager moves are held until it's idle and then given to [setChaptersNow].
|
||||
*/
|
||||
class PagerPageTracker(
|
||||
private val activity: ReaderActivity,
|
||||
private val items: PagerItems,
|
||||
private val setChaptersNow: (ViewerChapters) -> Unit,
|
||||
private val onPageHolderSelected: (page: ReaderPage, forward: Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
var currentPage: Any? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
|
||||
* or dragging, there'd be a noticeable and annoying jump.
|
||||
*/
|
||||
private var awaitingIdleViewerChapters: ViewerChapters? = null
|
||||
|
||||
/**
|
||||
* Whether the pager is currently in idle mode. It sets the awaiting chapters if setting
|
||||
* this field to true.
|
||||
*/
|
||||
var isIdle = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
awaitingIdleViewerChapters?.let { viewerChapters ->
|
||||
setChaptersNow(viewerChapters)
|
||||
awaitingIdleViewerChapters = null
|
||||
if (viewerChapters.currChapter.pages?.size == 1) {
|
||||
items.nextTransition?.to?.let(activity::requestPreloadChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given [chapters] now if the pager is idle, otherwise once it becomes idle.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
if (isIdle) {
|
||||
setChaptersNow(chapters)
|
||||
} else {
|
||||
awaitingIdleViewerChapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
|
||||
*/
|
||||
fun onPageChange(position: Int) {
|
||||
val page = items.items.getOrNull(position)
|
||||
if (page != null && currentPage != page) {
|
||||
val allowPreload = checkAllowPreload(page as? ReaderPage)
|
||||
val forward = when {
|
||||
currentPage is ReaderPage && page is ReaderPage -> {
|
||||
// if both pages have the same number, it's a split page with an InsertPage
|
||||
if (page.number == (currentPage as ReaderPage).number) {
|
||||
// the InsertPage is always the second in the reading direction
|
||||
page is InsertPage
|
||||
} else {
|
||||
page.number > (currentPage as ReaderPage).number
|
||||
}
|
||||
}
|
||||
currentPage is ChapterTransition.Prev && page is ReaderPage ->
|
||||
false
|
||||
else -> true
|
||||
}
|
||||
currentPage = page
|
||||
when (page) {
|
||||
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
|
||||
is ChapterTransition -> onTransitionSelected(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAllowPreload(page: ReaderPage?): Boolean {
|
||||
// Page is transition page - preload allowed
|
||||
page ?: return true
|
||||
|
||||
// Initial opening - preload allowed
|
||||
currentPage ?: return true
|
||||
|
||||
// Allow preload for
|
||||
// 1. Going to next chapter from chapter transition
|
||||
// 2. Going between pages of same chapter
|
||||
// 3. Next chapter page
|
||||
return when (page.chapter) {
|
||||
(currentPage as? ChapterTransition.Next)?.to -> true
|
||||
(currentPage as? ReaderPage)?.chapter -> true
|
||||
items.nextTransition?.to -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [ReaderPage] is marked as active. It notifies the
|
||||
* activity of the change and requests the preload of the next chapter if this is the last page.
|
||||
*/
|
||||
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
|
||||
val pages = page.chapter.pages ?: return
|
||||
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
|
||||
activity.onPageSelected(page)
|
||||
|
||||
// Notify holder of page change
|
||||
onPageHolderSelected(page, forward)
|
||||
|
||||
// Skip preload on inserts it causes unwanted page jumping
|
||||
if (page is InsertPage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Preload next chapter once we're within the last 5 pages of the current chapter
|
||||
val inPreloadRange = pages.size - page.number < 5
|
||||
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
|
||||
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
|
||||
items.nextTransition?.to?.let(activity::requestPreloadChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [ChapterTransition] is marked as active. It request the
|
||||
* preload of the destination chapter of the transition.
|
||||
*/
|
||||
private fun onTransitionSelected(transition: ChapterTransition) {
|
||||
logcat { "onTransitionSelected: $transition" }
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
logcat { "Request preload destination chapter because we're on the transition" }
|
||||
activity.requestPreloadChapter(toChapter)
|
||||
} else if (transition is ChapterTransition.Next) {
|
||||
// No more chapters, show menu because the user is probably going to close the reader
|
||||
activity.showMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-9
@@ -3,12 +3,25 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
|
||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* View of the ViewPager that contains a chapter transition.
|
||||
@@ -20,12 +33,25 @@ class PagerTransitionHolder(
|
||||
val transition: ChapterTransition,
|
||||
) : LinearLayout(readerThemedContext), ViewPagerAdapter.PositionableView {
|
||||
|
||||
private val scope = MainScope()
|
||||
private var stateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Item that identifies this view. Needed by the adapter to not recreate views.
|
||||
*/
|
||||
override val item: Any
|
||||
get() = transition
|
||||
|
||||
/**
|
||||
* View container of the current status of the transition page. Child views will be added
|
||||
* dynamically.
|
||||
*/
|
||||
private var pagesContainer = LinearLayout(context).apply {
|
||||
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
orientation = VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
@@ -34,16 +60,92 @@ class PagerTransitionHolder(
|
||||
|
||||
val transitionView = ReaderTransitionView(context)
|
||||
addView(transitionView)
|
||||
addView(pagesContainer)
|
||||
|
||||
transitionView.bind(
|
||||
transition = transition,
|
||||
downloadState = viewer.downloadManager.transitionDownloadState(
|
||||
transition,
|
||||
viewer.activity.viewModel.manga,
|
||||
viewer.activity.viewModel.source,
|
||||
),
|
||||
onRetry = viewer.activity::requestPreloadChapter,
|
||||
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
|
||||
transition,
|
||||
viewer.downloadManager,
|
||||
viewer.activity.viewModel.manga,
|
||||
viewer.activity.viewModel.source,
|
||||
)
|
||||
|
||||
transition.to?.let(::observeStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this view is detached from the window. Unsubscribes any active subscription.
|
||||
*/
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
stateJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
|
||||
* state, the pages container is cleaned up before setting the new state.
|
||||
*/
|
||||
private fun observeStatus(chapter: ReaderChapter) {
|
||||
stateJob?.cancel()
|
||||
stateJob = scope.launch {
|
||||
chapter.stateFlow
|
||||
.collectLatest { state ->
|
||||
pagesContainer.removeAllViews()
|
||||
when (state) {
|
||||
is ReaderChapter.State.Loading -> setLoading()
|
||||
is ReaderChapter.State.Error -> setError(state.error)
|
||||
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
|
||||
// No additional view is added
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the loading state on the pages container.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
val progress = CircularProgressIndicator(context)
|
||||
progress.isIndeterminate = true
|
||||
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.transition_pages_loading)
|
||||
}
|
||||
|
||||
pagesContainer.addView(progress)
|
||||
pagesContainer.addView(textView)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error state on the pages container.
|
||||
*/
|
||||
private fun setError(error: Throwable) {
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
|
||||
}
|
||||
|
||||
val retryBtn = ReaderButton(context).apply {
|
||||
viewer = this@PagerTransitionHolder.viewer
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.action_retry)
|
||||
setOnClickListener {
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
this@PagerTransitionHolder.viewer.activity.requestPreloadChapter(toChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pagesContainer.addView(textView)
|
||||
pagesContainer.addView(retryBtn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension method to set layout params to wrap content on this view.
|
||||
*/
|
||||
private fun View.wrapContent() {
|
||||
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ColorFilter
|
||||
import android.view.ViewGroup
|
||||
import android.graphics.PointF
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asAndroidColorFilter
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
@@ -22,14 +16,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.GeneralSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.PagedSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.collectChanges
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.mouseWheelPaging
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.position
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import mihon.app.di.appGraph
|
||||
@@ -44,6 +32,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
|
||||
val graph by lazy { activity.appGraph }
|
||||
val downloadManager by lazy { graph.downloadManager }
|
||||
val readerPreferences by lazy { graph.readerPreferences }
|
||||
|
||||
private val scope = MainScope()
|
||||
|
||||
@@ -53,56 +42,55 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
*/
|
||||
val pager = createPager()
|
||||
|
||||
private val settingsViewModel = activity.settingsViewModel
|
||||
|
||||
/**
|
||||
* Settings every paged viewer uses, like scale mode on images or tap zones.
|
||||
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
|
||||
*/
|
||||
val paged: PagedSettings
|
||||
get() = settingsViewModel.paged.value
|
||||
|
||||
/**
|
||||
* Settings only the pager viewers use, like splitting double pages.
|
||||
*/
|
||||
val simplePaged: SimplePagedSettings
|
||||
get() = settingsViewModel.simplePaged.value
|
||||
|
||||
/**
|
||||
* Settings shared by every viewer, like allow taps or page transitions.
|
||||
*/
|
||||
val general: GeneralSettings
|
||||
get() = settingsViewModel.general.value
|
||||
|
||||
val imageZoomType: ZoomStartPosition
|
||||
get() = paged.page.zoomStart.position(
|
||||
auto = when (this) {
|
||||
is L2RPagerViewer -> ZoomStartPosition.LEFT
|
||||
is R2LPagerViewer -> ZoomStartPosition.RIGHT
|
||||
else -> ZoomStartPosition.CENTER
|
||||
},
|
||||
)
|
||||
|
||||
private val pagerItems = PagerItems(isReversed = this is R2LPagerViewer)
|
||||
val config = PagerConfig(this, scope, readerPreferences)
|
||||
|
||||
/**
|
||||
* Adapter of the pager.
|
||||
*/
|
||||
private val adapter = PagerViewerAdapter(this, pagerItems)
|
||||
private val adapter = PagerViewerAdapter(this)
|
||||
|
||||
private val tracker = PagerPageTracker(activity, pagerItems, ::setChaptersInternal) { page, forward ->
|
||||
getPageHolder(page)?.onPageSelected(forward)
|
||||
}
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
private var currentPage: Any? = null
|
||||
|
||||
/**
|
||||
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
|
||||
* or dragging, there'd be a noticeable and annoying jump.
|
||||
*/
|
||||
private var awaitingIdleViewerChapters: ViewerChapters? = null
|
||||
|
||||
/**
|
||||
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
|
||||
* this field to true.
|
||||
*/
|
||||
private var isIdle = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
awaitingIdleViewerChapters?.let { viewerChapters ->
|
||||
setChaptersInternal(viewerChapters)
|
||||
awaitingIdleViewerChapters = null
|
||||
if (viewerChapters.currChapter.pages?.size == 1) {
|
||||
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val pagerListener = object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
if (!activity.isScrollingThroughPages) {
|
||||
activity.hideMenu()
|
||||
}
|
||||
tracker.onPageChange(position)
|
||||
onPageChange(position)
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
tracker.isIdle = state == ViewPager.SCROLL_STATE_IDLE
|
||||
isIdle = state == ViewPager.SCROLL_STATE_IDLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +98,6 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
pager.isVisible = false // Don't layout the pager yet
|
||||
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
pager.isFocusable = false
|
||||
pager.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
pager.offscreenPageLimit = 1
|
||||
pager.id = R.id.reader_pager
|
||||
pager.adapter = adapter
|
||||
@@ -120,14 +107,20 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
pager.getLocationOnScreen(viewPosition)
|
||||
val viewPositionRelativeToWindow = IntArray(2)
|
||||
pager.getLocationInWindow(viewPositionRelativeToWindow)
|
||||
val pos = Offset(
|
||||
val pos = PointF(
|
||||
(event.rawX - viewPosition[0] + viewPositionRelativeToWindow[0]) / pager.width,
|
||||
(event.rawY - viewPosition[1] + viewPositionRelativeToWindow[1]) / pager.height,
|
||||
)
|
||||
activity.onViewerTap(pos)
|
||||
when (config.navigator.getAction(pos)) {
|
||||
NavigationRegion.MENU -> activity.toggleMenu()
|
||||
NavigationRegion.NEXT -> moveToNext()
|
||||
NavigationRegion.PREV -> moveToPrevious()
|
||||
NavigationRegion.RIGHT -> moveRight()
|
||||
NavigationRegion.LEFT -> moveLeft()
|
||||
}
|
||||
}
|
||||
pager.longTapListener = f@{
|
||||
if (activity.viewModel.state.value.menuVisible || general.longTapEnabled) {
|
||||
if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) {
|
||||
val item = adapter.items.getOrNull(pager.currentItem)
|
||||
if (item is ReaderPage) {
|
||||
activity.onPageLongTap(item)
|
||||
@@ -137,23 +130,19 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
false
|
||||
}
|
||||
|
||||
settingsViewModel.general.collectChanges(scope) { old, new ->
|
||||
if (old != null && old.theme != new.theme) {
|
||||
refreshAdapter()
|
||||
}
|
||||
}
|
||||
settingsViewModel.simplePaged.collectChanges(scope) { old, new ->
|
||||
if (old != null) {
|
||||
refreshAdapter()
|
||||
}
|
||||
if (old?.dualPageSplit != new.dualPageSplit && !new.dualPageSplit) {
|
||||
config.dualPageSplitChangedListener = { enabled ->
|
||||
if (!enabled) {
|
||||
cleanupPageSplit()
|
||||
}
|
||||
}
|
||||
settingsViewModel.paged.collectChanges(scope) { old, new ->
|
||||
if (old != null && old.page != new.page) {
|
||||
refreshAdapter()
|
||||
}
|
||||
|
||||
config.imagePropertyChangedListener = {
|
||||
refreshAdapter()
|
||||
}
|
||||
|
||||
config.navigationModeChangedListener = {
|
||||
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
|
||||
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,23 +156,11 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
*/
|
||||
abstract fun createPager(): Pager
|
||||
|
||||
/** The page images' colours, for holders made later - see [LocalPageColorFilter]. */
|
||||
var pageColorFilter: ColorFilter? = null
|
||||
private set
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val filter = LocalPageColorFilter.current
|
||||
val pageFilter = remember(filter) { filter?.asAndroidColorFilter() }
|
||||
SideEffect {
|
||||
pageColorFilter = pageFilter
|
||||
// The pager keeps every page it holds as a child.
|
||||
pager.children.filterIsInstance<PagerPageHolder>().forEach { it.pageColorFilter = pageFilter }
|
||||
}
|
||||
AndroidView(
|
||||
factory = { context -> DisallowInterceptBarrier(context).apply { addView(pager) } },
|
||||
modifier = modifier.mouseWheelPaging(this),
|
||||
)
|
||||
/**
|
||||
* Returns the view this viewer uses.
|
||||
*/
|
||||
override fun getView(): View {
|
||||
return pager
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,12 +171,105 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
.filterIsInstance(PagerPageHolder::class.java)
|
||||
.firstOrNull { it.item == page }
|
||||
|
||||
/**
|
||||
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
|
||||
*/
|
||||
private fun onPageChange(position: Int) {
|
||||
val page = adapter.items.getOrNull(position)
|
||||
if (page != null && currentPage != page) {
|
||||
val allowPreload = checkAllowPreload(page as? ReaderPage)
|
||||
val forward = when {
|
||||
currentPage is ReaderPage && page is ReaderPage -> {
|
||||
// if both pages have the same number, it's a split page with an InsertPage
|
||||
if (page.number == (currentPage as ReaderPage).number) {
|
||||
// the InsertPage is always the second in the reading direction
|
||||
page is InsertPage
|
||||
} else {
|
||||
page.number > (currentPage as ReaderPage).number
|
||||
}
|
||||
}
|
||||
currentPage is ChapterTransition.Prev && page is ReaderPage ->
|
||||
false
|
||||
else -> true
|
||||
}
|
||||
currentPage = page
|
||||
when (page) {
|
||||
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
|
||||
is ChapterTransition -> onTransitionSelected(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAllowPreload(page: ReaderPage?): Boolean {
|
||||
// Page is transition page - preload allowed
|
||||
page ?: return true
|
||||
|
||||
// Initial opening - preload allowed
|
||||
currentPage ?: return true
|
||||
|
||||
// Allow preload for
|
||||
// 1. Going to next chapter from chapter transition
|
||||
// 2. Going between pages of same chapter
|
||||
// 3. Next chapter page
|
||||
return when (page.chapter) {
|
||||
(currentPage as? ChapterTransition.Next)?.to -> true
|
||||
(currentPage as? ReaderPage)?.chapter -> true
|
||||
adapter.nextTransition?.to -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [ReaderPage] is marked as active. It notifies the
|
||||
* activity of the change and requests the preload of the next chapter if this is the last page.
|
||||
*/
|
||||
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
|
||||
val pages = page.chapter.pages ?: return
|
||||
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
|
||||
activity.onPageSelected(page)
|
||||
|
||||
// Notify holder of page change
|
||||
getPageHolder(page)?.onPageSelected(forward)
|
||||
|
||||
// Skip preload on inserts it causes unwanted page jumping
|
||||
if (page is InsertPage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Preload next chapter once we're within the last 5 pages of the current chapter
|
||||
val inPreloadRange = pages.size - page.number < 5
|
||||
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
|
||||
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
|
||||
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [ChapterTransition] is marked as active. It request the
|
||||
* preload of the destination chapter of the transition.
|
||||
*/
|
||||
private fun onTransitionSelected(transition: ChapterTransition) {
|
||||
logcat { "onTransitionSelected: $transition" }
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
logcat { "Request preload destination chapter because we're on the transition" }
|
||||
activity.requestPreloadChapter(toChapter)
|
||||
} else if (transition is ChapterTransition.Next) {
|
||||
// No more chapters, show menu because the user is probably going to close the reader
|
||||
activity.showMenu()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
|
||||
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
|
||||
*/
|
||||
override fun setChapters(chapters: ViewerChapters) {
|
||||
tracker.setChapters(chapters)
|
||||
if (isIdle) {
|
||||
setChaptersInternal(chapters)
|
||||
} else {
|
||||
awaitingIdleViewerChapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +279,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
// Remove listener so the change in item doesn't trigger it
|
||||
pager.removeOnPageChangeListener(pagerListener)
|
||||
|
||||
val forceTransition = general.alwaysShowChapterTransition ||
|
||||
val forceTransition = config.alwaysShowChapterTransition ||
|
||||
adapter.items.getOrNull(pager.currentItem) is ChapterTransition
|
||||
adapter.setChapters(chapters, forceTransition)
|
||||
|
||||
@@ -223,7 +293,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
|
||||
pager.addOnPageChangeListener(pagerListener)
|
||||
// Manually call onPageChange to update the UI
|
||||
tracker.onPageChange(pager.currentItem)
|
||||
onPageChange(pager.currentItem)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +306,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
pager.setCurrentItem(position, true)
|
||||
// manually call onPageChange since ViewPager listener is not triggered in this case
|
||||
if (currentPosition == position) {
|
||||
tracker.onPageChange(position)
|
||||
onPageChange(position)
|
||||
}
|
||||
} else {
|
||||
logcat { "Page $page not found in adapter" }
|
||||
@@ -244,24 +314,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether pages run right to left, so moving forward goes left.
|
||||
* Moves to the next page.
|
||||
*/
|
||||
protected open val isReversed = false
|
||||
open fun moveToNext() {
|
||||
moveRight()
|
||||
}
|
||||
|
||||
override fun moveForward() = if (isReversed) moveLeft() else moveRight()
|
||||
|
||||
override fun moveBackward() = if (isReversed) moveRight() else moveLeft()
|
||||
/**
|
||||
* Moves to the previous page.
|
||||
*/
|
||||
open fun moveToPrevious() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the right.
|
||||
*/
|
||||
private fun moveRight() {
|
||||
protected open fun moveRight() {
|
||||
if (pager.currentItem != adapter.count - 1) {
|
||||
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
|
||||
if (holder != null && paged.navigateToPan && holder.canPanRight()) {
|
||||
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
|
||||
if (holder != null && config.navigateToPan && holder.canPanRight()) {
|
||||
holder.panRight()
|
||||
} else {
|
||||
pager.setCurrentItem(pager.currentItem + 1, general.usePageTransitions)
|
||||
pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,17 +344,31 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
/**
|
||||
* Moves to the page at the left.
|
||||
*/
|
||||
private fun moveLeft() {
|
||||
protected open fun moveLeft() {
|
||||
if (pager.currentItem != 0) {
|
||||
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
|
||||
if (holder != null && paged.navigateToPan && holder.canPanLeft()) {
|
||||
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
|
||||
if (holder != null && config.navigateToPan && holder.canPanLeft()) {
|
||||
holder.panLeft()
|
||||
} else {
|
||||
pager.setCurrentItem(pager.currentItem - 1, general.usePageTransitions)
|
||||
pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the top (or previous).
|
||||
*/
|
||||
protected open fun moveUp() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the bottom (or next).
|
||||
*/
|
||||
protected open fun moveDown() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the adapter in order to recreate all the views. Used when a image configuration is
|
||||
* changed.
|
||||
@@ -291,6 +380,69 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
pager.setCurrentItem(currentItem, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a key [event] is received. It should return true
|
||||
* if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
val isUp = event.action == KeyEvent.ACTION_UP
|
||||
val ctrlPressed = event.metaState.and(KeyEvent.META_CTRL_ON) > 0
|
||||
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) moveDown() else moveUp()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) moveUp() else moveDown()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
if (isUp) {
|
||||
if (ctrlPressed) moveToNext() else moveRight()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
if (isUp) {
|
||||
if (ctrlPressed) moveToPrevious() else moveLeft()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
|
||||
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
|
||||
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
|
||||
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a generic motion [event] is received. It should
|
||||
* return true if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
|
||||
moveDown()
|
||||
} else {
|
||||
moveUp()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onPageSplit(currentPage: ReaderPage, newPage: InsertPage) {
|
||||
activity.runOnUiThread {
|
||||
// Need to insert on UI thread else images will go blank
|
||||
@@ -302,12 +454,3 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
||||
adapter.cleanupPageSplit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps [requestDisallowInterceptTouchEvent] from reaching Compose. Its interop filter cancels the
|
||||
* gesture when a child re-allows intercepting mid-drag, which stops a zoomed page from handing the
|
||||
* drag to the pager at its edge.
|
||||
*/
|
||||
private class DisallowInterceptBarrier(context: Context) : FrameLayout(context) {
|
||||
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit
|
||||
}
|
||||
|
||||
+124
-12
@@ -4,8 +4,10 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
|
||||
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -13,16 +15,23 @@ import tachiyomi.core.common.util.system.logcat
|
||||
/**
|
||||
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class PagerViewerAdapter(
|
||||
private val viewer: PagerViewer,
|
||||
private val pagerItems: PagerItems,
|
||||
) : ViewPagerAdapter() {
|
||||
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
val items: List<Any>
|
||||
get() = pagerItems.items
|
||||
var items: MutableList<Any> = mutableListOf()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Holds preprocessed items so they don't get removed when changing chapter
|
||||
*/
|
||||
private var preprocessed: List<InsertPage> = emptyList()
|
||||
|
||||
var nextTransition: ChapterTransition.Next? = null
|
||||
private set
|
||||
|
||||
var currentChapter: ReaderChapter? = null
|
||||
|
||||
/**
|
||||
* Context that has been wrapped to use the correct theme values based on the
|
||||
@@ -36,13 +45,85 @@ class PagerViewerAdapter(
|
||||
* has R2L direction.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
||||
val update = pagerItems.setChapters(chapters, forceTransition, viewer.pager.currentItem)
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Forces chapter transition if there is missing chapters
|
||||
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
|
||||
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
||||
|
||||
// Add previous chapter pages and transition
|
||||
chapters.prevChapter?.pages?.let(newItems::addAll)
|
||||
|
||||
// Skip transition page if the chapter is loaded & current page is not a transition page
|
||||
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
}
|
||||
|
||||
var insertPageLastPage: InsertPage? = null
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
if (currPages != null) {
|
||||
val pages = currPages.toMutableList()
|
||||
|
||||
val lastPage = pages.last()
|
||||
|
||||
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
|
||||
// placed by their index, so only this chapter's belong here.
|
||||
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
|
||||
.sortedByDescending { it.index }
|
||||
.forEach { page ->
|
||||
if (lastPage.index == page.index) {
|
||||
insertPageLastPage = page
|
||||
}
|
||||
pages.add(page.index + 1, page)
|
||||
}
|
||||
|
||||
newItems.addAll(pages)
|
||||
}
|
||||
|
||||
currentChapter = chapters.currChapter
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
|
||||
.also {
|
||||
if (
|
||||
nextHasMissingChapters ||
|
||||
forceTransition ||
|
||||
chapters.nextChapter?.state !is ReaderChapter.State.Loaded
|
||||
) {
|
||||
newItems.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||
|
||||
val currentItem = items.getOrNull(viewer.pager.currentItem)
|
||||
|
||||
// Resets double-page splits, else insert pages get misplaced
|
||||
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
|
||||
|
||||
if (viewer is R2LPagerViewer) {
|
||||
newItems.reverse()
|
||||
}
|
||||
|
||||
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
|
||||
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
|
||||
update.position?.let { viewer.pager.setCurrentItem(it, false) }
|
||||
// The pager only follows the pages it has created, so before its first layout it would stay at the same
|
||||
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
|
||||
// chapter
|
||||
val currentPosition = items.indexOf(currentItem)
|
||||
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
|
||||
viewer.pager.setCurrentItem(currentPosition, false)
|
||||
}
|
||||
|
||||
// Will skip insert page otherwise
|
||||
update.insertPage?.let(viewer::moveToPage)
|
||||
if (insertPageLastPage != null) {
|
||||
viewer.moveToPage(insertPageLastPage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,13 +160,44 @@ class PagerViewerAdapter(
|
||||
}
|
||||
|
||||
fun onPageSplit(currentPage: Any?, newPage: InsertPage) {
|
||||
if (pagerItems.onPageSplit(currentPage, newPage)) {
|
||||
notifyDataSetChanged()
|
||||
if (currentPage !is ReaderPage) return
|
||||
|
||||
val currentIndex = items.indexOf(currentPage)
|
||||
|
||||
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
|
||||
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
|
||||
val chapterId = newPage.chapter.chapter.id
|
||||
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
|
||||
preprocessed = preprocessed + newPage
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val placeAtIndex = when (viewer) {
|
||||
is L2RPagerViewer,
|
||||
is VerticalPagerViewer,
|
||||
-> currentIndex + 1
|
||||
else -> currentIndex
|
||||
}
|
||||
|
||||
// It will enter a endless cycle of insert pages
|
||||
if (viewer is R2LPagerViewer && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Same here it will enter a endless cycle of insert pages
|
||||
if (items[placeAtIndex] is InsertPage) {
|
||||
return
|
||||
}
|
||||
|
||||
items.add(placeAtIndex, newPage)
|
||||
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun cleanupPageSplit() {
|
||||
pagerItems.cleanupPageSplit()
|
||||
val insertPages = items.filterIsInstance<InsertPage>()
|
||||
items.removeAll(insertPages)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,19 @@ class R2LPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
return Pager(activity)
|
||||
}
|
||||
|
||||
override val isReversed = true
|
||||
/**
|
||||
* Moves to the next page. On a R2L pager the next page is the one at the left.
|
||||
*/
|
||||
override fun moveToNext() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page. On a R2L pager the previous page is the one at the right.
|
||||
*/
|
||||
override fun moveToPrevious() {
|
||||
moveRight()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
|
||||
|
||||
/**
|
||||
* Settings only the pager viewers use, all of them part of what the pages are made with.
|
||||
*/
|
||||
data class SimplePagedSettings(
|
||||
override val dualPageSplit: Boolean,
|
||||
override val dualPageInvert: Boolean,
|
||||
override val dualPageRotateToFit: Boolean,
|
||||
override val dualPageRotateToFitInvert: Boolean,
|
||||
) : DualPageSettings
|
||||
@@ -1,208 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import ca.mpreg.webgpuviewer.viewer.ComposePageSize
|
||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||
import eu.kanade.presentation.reader.ChapterTransition
|
||||
import eu.kanade.presentation.reader.ReaderError
|
||||
import eu.kanade.presentation.util.formattedMessage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderThemedContent
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import tachiyomi.presentation.core.components.CombinedCircularProgressIndicator
|
||||
import tachiyomi.source.local.isLocal
|
||||
|
||||
/** The side padding `PagerTransitionHolder` gave the same transition view. */
|
||||
private const val TRANSITION_SIDE_PADDING = 64
|
||||
|
||||
/** The padding `WebtoonTransitionHolder` gave it, which took only the height it needed. */
|
||||
private const val STRIP_TRANSITION_SIDE_PADDING = 32
|
||||
private const val STRIP_TRANSITION_VERTICAL_PADDING = 128
|
||||
|
||||
/** How much of the viewport `WebtoonPageHolder` gave its error layout. */
|
||||
private const val STRIP_ERROR_HEIGHT_FRACTION = 0.8f
|
||||
|
||||
/** The top margin `WebtoonPageHolder` gave its spinner, as a fraction of the viewport. */
|
||||
private const val STRIP_PROGRESS_TOP_FRACTION = 0.25f
|
||||
|
||||
/**
|
||||
* The reader's own chapter and page chrome, as composables, in the reader's theme.
|
||||
*
|
||||
* Built on the reader background, which is fixed for the page's life: a theme change rebuilds every
|
||||
* page (see [WebGpuViewer]'s image property listener).
|
||||
*/
|
||||
abstract class ReaderComposePage(
|
||||
protected val viewer: WebGpuViewer,
|
||||
size: ComposePageSize = ComposePageSize.Auto,
|
||||
) : ImagePage.Compose(size, viewer.readerBackgroundColor()) {
|
||||
|
||||
/**
|
||||
* The theme goes on here because the library's page knows nothing of the app's colours.
|
||||
*/
|
||||
@Composable
|
||||
final override fun BoxScope.Content() {
|
||||
ReaderThemedContent(viewer.readerThemedContext) { PageContent() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
protected abstract fun PageContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A page that failed to load or decode, with the pager's error layout: the message, Retry, and
|
||||
* Open in WebView for an image that came off the web.
|
||||
*/
|
||||
class ErrorPage internal constructor(
|
||||
viewer: WebGpuViewer,
|
||||
private val viewerPage: WebGpuViewer.ViewerReaderPage?,
|
||||
private val error: Throwable?,
|
||||
) : ReaderComposePage(
|
||||
viewer,
|
||||
// WebtoonPageHolder gave its error layout less than a screen, so the page below shows through.
|
||||
if (viewer is WebGpuViewerContinuous) ComposePageSize.Wrap else ComposePageSize.Auto,
|
||||
) {
|
||||
|
||||
@Composable
|
||||
override fun PageContent() {
|
||||
val context = LocalContext.current
|
||||
// As the pager's own error layout: only offered for an image that came off the web.
|
||||
val imageUrl = viewerPage?.page?.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
|
||||
val strip = if (viewer is WebGpuViewerContinuous) {
|
||||
Modifier.fillMaxHeight(STRIP_ERROR_HEIGHT_FRACTION).wrapContentHeight()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
ReaderError(
|
||||
modifier = strip,
|
||||
message = with(context) { error?.formattedMessage },
|
||||
onRetry = { viewerPage?.let { viewer.retryLoad(it) } },
|
||||
onOpenInWebView = imageUrl?.let {
|
||||
{
|
||||
val sourceId = viewer.activity.viewModel.manga?.source
|
||||
context.startActivity(WebViewActivity.newIntent(context, it, sourceId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A page still downloading: the reader's progress ring. */
|
||||
class ProgressPage(viewer: WebGpuViewer) : ReaderComposePage(viewer) {
|
||||
|
||||
/**
|
||||
* 0 to 1. On the page, not in the composition, which is dropped when the page goes out of
|
||||
* reach. Written from the loader's collector; snapshot state takes writes from any thread.
|
||||
*/
|
||||
var progress: Float
|
||||
get() = progressState
|
||||
set(value) {
|
||||
progressState = value.fastCoerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private var progressState by mutableFloatStateOf(0f)
|
||||
|
||||
@Composable
|
||||
override fun PageContent() {
|
||||
if (viewer !is WebGpuViewerContinuous) {
|
||||
CombinedCircularProgressIndicator(progress = { progress })
|
||||
return
|
||||
}
|
||||
// WebtoonPageHolder centred its spinner below a quarter-height top margin, which puts it
|
||||
// at five eighths of the slot rather than halfway down.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = maxHeight * STRIP_PROGRESS_TOP_FRACTION),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CombinedCircularProgressIndicator(progress = { progress })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The page between two chapters, or before the first and after the last. */
|
||||
class TransitionPage(
|
||||
viewer: WebGpuViewer,
|
||||
val prevChapter: ReaderChapter?,
|
||||
val nextChapter: ReaderChapter?,
|
||||
) : ReaderComposePage(viewer, ComposePageSize.Wrap) {
|
||||
|
||||
/** As the pager and webtoon adapters framed it: leaving [prevChapter] for [nextChapter]. */
|
||||
private val transition: ChapterTransition? = when {
|
||||
prevChapter != null -> ChapterTransition.Next(prevChapter, nextChapter)
|
||||
nextChapter != null -> ChapterTransition.Prev(nextChapter, null)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun PageContent() {
|
||||
val transition = transition ?: return
|
||||
val goingToChapterDownloaded by produceState(false, transition) {
|
||||
value = withContext(Dispatchers.IO) { isDownloaded(transition) }
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
// The padding the holder each viewer used to inflate this into gave it.
|
||||
val padding = if (viewer is WebGpuViewerContinuous) {
|
||||
Modifier.padding(
|
||||
horizontal = STRIP_TRANSITION_SIDE_PADDING.dp,
|
||||
vertical = STRIP_TRANSITION_VERTICAL_PADDING.dp,
|
||||
)
|
||||
} else {
|
||||
Modifier.padding(horizontal = TRANSITION_SIDE_PADDING.dp)
|
||||
}
|
||||
Box(modifier = padding.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
|
||||
ChapterTransition(
|
||||
transition = transition,
|
||||
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
|
||||
goingToChapterDownloaded = goingToChapterDownloaded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `ReaderTransitionView.bind`'s answer, which reads the disk. */
|
||||
private suspend fun isDownloaded(transition: ChapterTransition): Boolean {
|
||||
val manga = viewer.activity.viewModel.manga ?: return false
|
||||
if (manga.isLocal()) return true
|
||||
val chapter = transition.to?.chapter ?: return false
|
||||
val source = viewer.activity.viewModel.source ?: return false
|
||||
return viewer.downloadManager.isChapterDownloadedOnDisk(
|
||||
chapterName = chapter.name,
|
||||
chapterScanlator = chapter.scanlator,
|
||||
chapterUrl = chapter.url,
|
||||
mangaTitle = manga.title,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
/**
|
||||
* Settings only the long strip WebGPU viewer uses.
|
||||
*/
|
||||
data class WebGpuLongStripSettings(
|
||||
val page: Page,
|
||||
val common: WebGpuSettings,
|
||||
) {
|
||||
|
||||
/**
|
||||
* What the pages are made with, so a change remakes them.
|
||||
*/
|
||||
data class Page(
|
||||
val continuousMinWidth: Int,
|
||||
val continuousGap: Int,
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
|
||||
/**
|
||||
* Settings only the paged WebGPU viewers use.
|
||||
*/
|
||||
data class WebGpuPagedSettings(
|
||||
val page: Page,
|
||||
val common: WebGpuSettings,
|
||||
) {
|
||||
|
||||
/**
|
||||
* What the pages are made with, so a change remakes them.
|
||||
*/
|
||||
data class Page(
|
||||
val dualPageView: ReaderPreferences.DualPageView,
|
||||
val transitionAnimation: ReaderPreferences.TransitionAnimation,
|
||||
val transitionAnimationDual: ReaderPreferences.TransitionAnimation,
|
||||
val cutoutMode: ReaderPreferences.CutoutMode,
|
||||
val cutoutModeDual: ReaderPreferences.CutoutMode,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
/**
|
||||
* Settings both WebGPU viewers use, kept in each of their own settings.
|
||||
*/
|
||||
data class WebGpuSettings(
|
||||
val doubleTapGesturesDisabled: Boolean,
|
||||
val imageFilters: List<WebGpuShader>,
|
||||
val tileFilters: List<WebGpuShader>,
|
||||
)
|
||||
@@ -1,113 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import ca.mpreg.webgpuviewer.renderer.ImageFilterWgsl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** A user's shader: [file] in [WebGpuShaders.dir], shown as [name]. */
|
||||
data class WebGpuShader(val file: String, val name: String)
|
||||
|
||||
/** Picked shaders are copied in, so they outlive the picker's URI permission. */
|
||||
object WebGpuShaders {
|
||||
private const val MAX_BYTES = 1 shl 20
|
||||
|
||||
/** What [import] names files; anything else in a preference is not ours to read or delete. */
|
||||
private val FILE = Regex("""[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.wgsl""")
|
||||
|
||||
/** Held while files and the preferences naming them change together. */
|
||||
private val lock = Any()
|
||||
|
||||
private fun dir(context: Context) = File(context.filesDir, "webgpu_shaders")
|
||||
|
||||
fun serialize(shaders: List<WebGpuShader>): String =
|
||||
shaders.joinToString("\n") { "${it.file}\t${it.name}" }
|
||||
|
||||
fun deserialize(text: String): List<WebGpuShader> = text.lineSequence()
|
||||
.mapNotNull { line ->
|
||||
val tab = line.indexOf('\t')
|
||||
if (tab <= 0) return@mapNotNull null
|
||||
val file = line.substring(0, tab)
|
||||
if (FILE.matches(file)) WebGpuShader(file, line.substring(tab + 1)) else null
|
||||
}
|
||||
.distinctBy { it.file }
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Copies [uri] in and appends it to [pref]. Throws [IllegalArgumentException], with the reason, if
|
||||
* it is not a filter this device runs.
|
||||
*/
|
||||
suspend fun import(context: Context, uri: Uri, pref: Preference<List<WebGpuShader>>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val resolver = context.contentResolver
|
||||
val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||
if (it.moveToFirst()) it.getString(0) else null
|
||||
} ?: uri.lastPathSegment ?: "shader"
|
||||
val source = resolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(MAX_BYTES + 1)
|
||||
var size = 0
|
||||
while (size < buffer.size) {
|
||||
val read = input.read(buffer, size, buffer.size - size)
|
||||
if (read < 0) break
|
||||
size += read
|
||||
}
|
||||
require(size <= MAX_BYTES) { "$name is over ${MAX_BYTES shr 20} MB" }
|
||||
buffer.decodeToString(0, size)
|
||||
} ?: throw IllegalArgumentException("cannot open $name")
|
||||
ImageFilterWgsl(source, name).validate()
|
||||
|
||||
val shader = WebGpuShader("${UUID.randomUUID()}.wgsl", name.replace(Regex("""[\t\r\n]"""), " "))
|
||||
// Past validation, the copy and its entry land together or not at all.
|
||||
withContext(NonCancellable) {
|
||||
synchronized(lock) {
|
||||
val file = File(dir(context).apply { mkdirs() }, shader.file)
|
||||
try {
|
||||
file.writeText(source)
|
||||
pref.set(pref.get() + shader)
|
||||
} catch (e: Exception) {
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(context: Context, pref: Preference<List<WebGpuShader>>, shader: WebGpuShader) {
|
||||
synchronized(lock) {
|
||||
pref.set(pref.get().filter { it.file != shader.file })
|
||||
File(dir(context), shader.file).delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes copies neither of [prefs] names, and entries whose copy is gone - left by a restored
|
||||
* backup, or a crash between the two.
|
||||
*/
|
||||
suspend fun prune(context: Context, prefs: List<Preference<List<WebGpuShader>>>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
synchronized(lock) {
|
||||
val dir = dir(context)
|
||||
val present = dir.list()?.toSet().orEmpty()
|
||||
val named = HashSet<String>()
|
||||
for (pref in prefs) {
|
||||
val shaders = pref.get()
|
||||
val kept = shaders.filter { it.file in present }
|
||||
if (kept.size != shaders.size) pref.set(kept)
|
||||
kept.mapTo(named) { it.file }
|
||||
}
|
||||
for (file in present) {
|
||||
if (file !in named) File(dir, file).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws if the file is gone or no longer parses. */
|
||||
fun load(context: Context, shader: WebGpuShader): ImageFilterWgsl =
|
||||
ImageFilterWgsl(File(dir(context), shader.file).readText(), shader.name)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-111
@@ -1,111 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuous
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.mouseWheelPaging
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
|
||||
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
|
||||
WebGpuViewer(activity, isReversed = false, isVertical = true, viewerState = ImageViewerContinuousState()) {
|
||||
|
||||
override val isContinuous: Boolean get() = true
|
||||
|
||||
// How many pages the viewport shows depends on the zoom, and a page on screen has to be
|
||||
// decoded rather than merely reserved - so the window follows what the last frame reached.
|
||||
override val preloadAhead get() = max(3, state.pagesBelow)
|
||||
override val preloadBehind get() = max(2, state.pagesAbove)
|
||||
|
||||
override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
|
||||
|
||||
private val state get() = viewerState as ImageViewerContinuousState
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
ImageViewerContinuous(modifier = modifier.mouseWheelPaging(this), state = state)
|
||||
}
|
||||
|
||||
init {
|
||||
state.backgroundColor = readerBackgroundColor()
|
||||
|
||||
state.onViewport = { readThrough ->
|
||||
val pageChanged = readThrough !== lastReadThrough
|
||||
if (pageChanged) {
|
||||
lastReadThrough = readThrough
|
||||
readThrough?.let { select(it) }
|
||||
}
|
||||
|
||||
val page0 = state.getPage(0)
|
||||
val edge = (page0 is TransitionPage && page0.prevChapter == null) ||
|
||||
(readThrough is TransitionPage && readThrough.nextChapter == null)
|
||||
|
||||
val first = wasAtEdge == null
|
||||
val edgeChanged = edge != wasAtEdge
|
||||
wasAtEdge = edge
|
||||
if (edge) {
|
||||
if (edgeChanged) scope.launch { activity.showMenu() }
|
||||
} else if (!first && (edgeChanged || pageChanged) && !activity.isScrollingThroughPages) {
|
||||
scope.launch { activity.hideMenu() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Null until the first frame, so opening mid-document neither shows nor hides. */
|
||||
@Volatile
|
||||
private var wasAtEdge: Boolean? = null
|
||||
|
||||
@Volatile
|
||||
private var lastReadThrough: ImagePage? = null
|
||||
|
||||
/**
|
||||
* Mark [imagePage] read - its bottom has cleared the viewport, which is later than
|
||||
* onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to
|
||||
* reveal the page above would otherwise walk progress back past pages still on screen.
|
||||
*/
|
||||
private fun select(imagePage: ImagePage) {
|
||||
val page = readerPageFor(imagePage) ?: return
|
||||
scope.launch { activity.onPageSelected(page) }
|
||||
}
|
||||
|
||||
private fun readerPageFor(imagePage: ImagePage): ReaderPage? {
|
||||
// Off the page itself: a transition page is never currentPage (the scroll walk stops
|
||||
// before it), so it is the first thing a preload evicts.
|
||||
if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull()
|
||||
|
||||
// Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted
|
||||
// means scrolled well clear of, so there is no progress left to report.
|
||||
return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
state.onViewport = null
|
||||
lastReadThrough = null
|
||||
wasAtEdge = null
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
private fun scrollByHalfPage(direction: Int) {
|
||||
state.animateScroll(direction * state.height / 2f)
|
||||
}
|
||||
|
||||
override fun moveRight() = scrollByHalfPage(1)
|
||||
|
||||
override fun moveLeft() = scrollByHalfPage(-1)
|
||||
|
||||
override fun moveToPage(page: ReaderPage) {
|
||||
super.moveToPage(page)
|
||||
wasAtEdge = null
|
||||
lastReadThrough = null
|
||||
// Also for a jump to the page already showing, which turns nothing to slide in.
|
||||
state.resetScroll()
|
||||
}
|
||||
|
||||
override fun animateTurn(direction: Int, fromSpread: ImagePage) {
|
||||
state.animateSlideIn(direction)
|
||||
}
|
||||
}
|
||||
-366
@@ -1,366 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.manga.model.readerOrientation
|
||||
import eu.kanade.domain.manga.model.readingMode
|
||||
import eu.kanade.presentation.reader.settings.TapZonesItems
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.Add
|
||||
import mihon.icons.materialsymbols.rounded.Delete
|
||||
import mihon.icons.materialsymbols.rounded.DragHandle
|
||||
import sh.calvin.reorderable.ReorderableColumn
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.CheckboxItem
|
||||
import tachiyomi.presentation.core.components.HeadingItem
|
||||
import tachiyomi.presentation.core.components.SettingsChipRow
|
||||
import tachiyomi.presentation.core.components.SettingsItemsPaddings
|
||||
import tachiyomi.presentation.core.components.SliderItem
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
internal fun WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
|
||||
HeadingItem(MR.strings.pref_category_for_this_series)
|
||||
val manga by viewModel.mangaFlow.collectAsState()
|
||||
|
||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||
SettingsChipRow(MR.strings.pref_category_reading_mode) {
|
||||
ReadingMode.entries.map {
|
||||
FilterChip(
|
||||
selected = it == readingMode,
|
||||
onClick = { viewModel.setReadingMode(it) },
|
||||
label = { Text(stringResource(it.stringRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val resolved by viewModel.readingMode.collectAsState()
|
||||
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
|
||||
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
|
||||
SettingsChipRow(MR.strings.pref_dual_page_view) {
|
||||
ReaderPreferences.DualPageView.entries.map {
|
||||
FilterChip(
|
||||
selected = it == dualPageView,
|
||||
onClick = { viewModel.preferences.dualPageView.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val numberFormat = remember { NumberFormat.getPercentInstance() }
|
||||
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
|
||||
SliderItem(
|
||||
value = continuousMinWidth,
|
||||
valueRange = ReaderPreferences.let { 1..100 },
|
||||
label = stringResource(MR.strings.pref_continuous_minwidth),
|
||||
valueString = numberFormat.format(continuousMinWidth / 100f),
|
||||
onChange = {
|
||||
viewModel.preferences.continuousMinWidth.set(it)
|
||||
},
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
if (resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val continuousGap by viewModel.preferences.continuousGap.collectAsState()
|
||||
SliderItem(
|
||||
value = continuousGap,
|
||||
valueRange = ReaderPreferences.let { 1..100 },
|
||||
label = stringResource(MR.strings.pref_continuous_gap),
|
||||
valueString = numberFormat.format(continuousGap / 100f),
|
||||
onChange = {
|
||||
viewModel.preferences.continuousGap.set(it)
|
||||
},
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
}
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
|
||||
pref = viewModel.preferences.webtoonDisableZoomOut,
|
||||
)
|
||||
}
|
||||
|
||||
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
|
||||
SettingsChipRow(MR.strings.rotation_type) {
|
||||
ReaderOrientation.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == orientation,
|
||||
onClick = { viewModel.setOrientation(it) },
|
||||
label = { Text(stringResource(it.stringRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HeadingItem(MR.strings.webgpu_viewer)
|
||||
|
||||
// As the viewer decides it, with the activity's window in place of the viewer's size. Not the sheet's own window.
|
||||
val configuration = LocalConfiguration.current
|
||||
val isDual = resolved.type is ReadingMode.ViewerType.Pager &&
|
||||
when (dualPageView) {
|
||||
ReaderPreferences.DualPageView.NEVER -> false
|
||||
ReaderPreferences.DualPageView.ALWAYS -> true
|
||||
ReaderPreferences.DualPageView.WIDE -> configuration.screenWidthDp > configuration.screenHeightDp
|
||||
}
|
||||
|
||||
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
|
||||
val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState()
|
||||
TapZonesItems(
|
||||
selected = navigationModePager,
|
||||
onSelect = viewModel.preferences.navigationModePager::set,
|
||||
invertMode = pagerNavInverted,
|
||||
onSelectInvertMode = viewModel.preferences.pagerNavInverted::set,
|
||||
)
|
||||
|
||||
if (isDual) {
|
||||
val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_transition_animation_dual) {
|
||||
(
|
||||
ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT -
|
||||
ReaderPreferences.TransitionAnimation.FLIP_RIGHT
|
||||
).forEach {
|
||||
FilterChip(
|
||||
selected = it == transitionAnimation,
|
||||
onClick = { viewModel.preferences.transitionAnimationDual.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_cutout_mode_dual) {
|
||||
ReaderPreferences.CutoutMode.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == cutoutMode,
|
||||
onClick = { viewModel.preferences.cutoutModeDual.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_image_scale_type) {
|
||||
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
|
||||
FilterChip(
|
||||
selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it,
|
||||
onClick = {
|
||||
viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1)
|
||||
},
|
||||
label = { Text(stringResource(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val zoomStart by viewModel.preferences.zoomStart.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_zoom_start) {
|
||||
ReaderPreferences.ZoomStart.mapIndexed { index, it ->
|
||||
FilterChip(
|
||||
selected = zoomStart == index + 1,
|
||||
onClick = { viewModel.preferences.zoomStart.set(index + 1) },
|
||||
label = { Text(stringResource(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_crop_borders),
|
||||
pref = viewModel.preferences.cropBorders,
|
||||
)
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_landscape_zoom),
|
||||
pref = viewModel.preferences.landscapeZoom,
|
||||
)
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_navigate_pan),
|
||||
pref = viewModel.preferences.navigateToPan,
|
||||
)
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_disable_double_tap_gestures),
|
||||
pref = viewModel.preferences.disableDoubleTapGestures,
|
||||
)
|
||||
|
||||
val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_transition_animation) {
|
||||
ReaderPreferences.TransitionAnimation.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == transitionAnimation,
|
||||
onClick = { viewModel.preferences.transitionAnimation.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val cutoutMode by viewModel.preferences.cutoutMode.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_cutout_mode) {
|
||||
ReaderPreferences.CutoutMode.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == cutoutMode,
|
||||
onClick = { viewModel.preferences.cutoutMode.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
WebGpuShaders.prune(context, listOf(viewModel.preferences.imageFilters, viewModel.preferences.tileFilters))
|
||||
}
|
||||
ShaderList(MR.strings.pref_image_filters, viewModel.preferences.imageFilters)
|
||||
ShaderList(MR.strings.pref_tile_filters, viewModel.preferences.tileFilters)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShaderList(labelRes: StringResource, pref: Preference<List<WebGpuShader>>) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val filters by pref.collectAsState()
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val pickShader = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
try {
|
||||
WebGpuShaders.import(context, uri, pref)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e)
|
||||
error = e.message ?: e.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error?.let { message ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { error = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { error = null }) {
|
||||
Text(stringResource(MR.strings.action_ok))
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(MR.strings.image_filter_invalid)) },
|
||||
text = {
|
||||
SelectionContainer(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text = message,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HeadingItem(labelRes)
|
||||
ReorderableColumn(
|
||||
list = filters,
|
||||
onSettle = { from, to ->
|
||||
// The indices are into the list as drawn, which the preference may have moved past.
|
||||
val moved = filters.toMutableList().apply { add(to, removeAt(from)) }
|
||||
val current = pref.get()
|
||||
val files = current.mapTo(HashSet()) { it.file }
|
||||
val kept = moved.filter { it.file in files }
|
||||
val keptFiles = kept.mapTo(HashSet()) { it.file }
|
||||
pref.set(kept + current.filter { it.file !in keptFiles })
|
||||
},
|
||||
) { _, shader, _ ->
|
||||
key(shader.file) {
|
||||
ReorderableItem {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = SettingsItemsPaddings.Horizontal, end = MaterialTheme.padding.small),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.DragHandle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.draggableHandle(),
|
||||
)
|
||||
Text(
|
||||
text = shader.name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = MaterialTheme.padding.medium),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { WebGpuShaders.remove(context, pref, shader) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.Delete,
|
||||
contentDescription = stringResource(MR.strings.action_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
try {
|
||||
pickShader.launch(arrayOf("*/*"))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
error = e.message ?: e.toString()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = SettingsItemsPaddings.Horizontal - MaterialTheme.padding.small),
|
||||
) {
|
||||
Icon(imageVector = MaterialSymbols.Rounded.Add, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(MR.strings.action_add_shader),
|
||||
modifier = Modifier.padding(start = MaterialTheme.padding.small),
|
||||
)
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationSettings
|
||||
|
||||
/**
|
||||
* Settings only the webtoon viewers use.
|
||||
*/
|
||||
data class SimpleLongStripSettings(
|
||||
val page: Page,
|
||||
val doubleTapZoom: Boolean,
|
||||
val navigation: NavigationSettings,
|
||||
) {
|
||||
|
||||
/**
|
||||
* What the pages are made with, so a change remakes them.
|
||||
*/
|
||||
data class Page(
|
||||
val sidePadding: Int,
|
||||
override val dualPageSplit: Boolean,
|
||||
override val dualPageInvert: Boolean,
|
||||
override val dualPageRotateToFit: Boolean,
|
||||
override val dualPageRotateToFitInvert: Boolean,
|
||||
) : DualPageSettings
|
||||
}
|
||||
@@ -5,24 +5,25 @@ import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
|
||||
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
|
||||
|
||||
/**
|
||||
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class WebtoonAdapter(
|
||||
val viewer: WebtoonViewer,
|
||||
private val webtoonItems: WebtoonItems,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
val items: List<Any>
|
||||
get() = webtoonItems.items
|
||||
var items: List<Any> = emptyList()
|
||||
private set
|
||||
|
||||
var currentChapter: ReaderChapter? = null
|
||||
|
||||
/**
|
||||
* Context that has been wrapped to use the correct theme values based on the
|
||||
@@ -35,9 +36,42 @@ class WebtoonAdapter(
|
||||
* next/previous chapter to allow seamless transitions.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
||||
val oldItems = items
|
||||
webtoonItems.setChapters(chapters, forceTransition)
|
||||
DiffUtil.calculateDiff(Callback(oldItems, items)).dispatchUpdatesTo(this)
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Forces chapter transition if there is missing chapters
|
||||
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
|
||||
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
||||
|
||||
// Add previous chapter pages and transition.
|
||||
chapters.prevChapter?.pages?.let(newItems::addAll)
|
||||
|
||||
// Skip transition page if the chapter is loaded & current page is not a transition page
|
||||
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
}
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
if (currPages != null) {
|
||||
newItems.addAll(currPages)
|
||||
}
|
||||
|
||||
currentChapter = chapters.currChapter
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
}
|
||||
|
||||
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||
|
||||
updateItems(newItems)
|
||||
}
|
||||
|
||||
private fun updateItems(newItems: List<Any>) {
|
||||
val result = DiffUtil.calculateDiff(Callback(items, newItems))
|
||||
items = newItems
|
||||
result.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@@ -69,7 +103,6 @@ class WebtoonAdapter(
|
||||
return when (viewType) {
|
||||
PAGE_VIEW -> {
|
||||
val view = ReaderPageImageView(readerThemedContext, isWebtoon = true)
|
||||
viewer.onPageViewCreated(view)
|
||||
WebtoonPageHolder(view, viewer)
|
||||
}
|
||||
TRANSITION_VIEW -> {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
/**
|
||||
* Configuration used by webtoon viewers.
|
||||
*/
|
||||
class WebtoonConfig(
|
||||
scope: CoroutineScope,
|
||||
readerPreferences: ReaderPreferences,
|
||||
) : ViewerConfig(readerPreferences, scope) {
|
||||
|
||||
var themeChangedListener: (() -> Unit)? = null
|
||||
|
||||
var imageCropBorders = false
|
||||
private set
|
||||
|
||||
var zoomOutDisabled = false
|
||||
private set
|
||||
|
||||
var zoomPropertyChangedListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
var sidePadding = 0
|
||||
private set
|
||||
|
||||
var doubleTapZoom = true
|
||||
private set
|
||||
|
||||
var doubleTapZoomChangedListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
val theme = readerPreferences.readerTheme.get()
|
||||
|
||||
init {
|
||||
readerPreferences.cropBordersWebtoon
|
||||
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.webtoonSidePadding
|
||||
.register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.navigationModeWebtoon
|
||||
.register({ navigationMode = it }, { updateNavigation(it) })
|
||||
|
||||
readerPreferences.webtoonNavInverted
|
||||
.register({ tappingInverted = it }, { navigator.invertMode = it })
|
||||
readerPreferences.webtoonNavInverted.changes()
|
||||
.drop(1)
|
||||
.onEach { navigationModeChangedListener?.invoke() }
|
||||
.launchIn(scope)
|
||||
|
||||
readerPreferences.dualPageSplitWebtoon
|
||||
.register({ dualPageSplit = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.dualPageInvertWebtoon
|
||||
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.dualPageRotateToFitWebtoon
|
||||
.register(
|
||||
{ dualPageRotateToFit = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.dualPageRotateToFitInvertWebtoon
|
||||
.register(
|
||||
{ dualPageRotateToFitInvert = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.webtoonDisableZoomOut
|
||||
.register(
|
||||
{ zoomOutDisabled = it },
|
||||
{ zoomPropertyChangedListener?.invoke(it) },
|
||||
)
|
||||
|
||||
readerPreferences.webtoonDoubleTapZoomEnabled
|
||||
.register(
|
||||
{ doubleTapZoom = it },
|
||||
{ doubleTapZoomChangedListener?.invoke(it) },
|
||||
)
|
||||
|
||||
readerPreferences.readerTheme.changes()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.onEach { themeChangedListener?.invoke() }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override var navigator: ViewerNavigation = defaultNavigation()
|
||||
set(value) {
|
||||
field = value.also { it.invertMode = tappingInverted }
|
||||
}
|
||||
|
||||
override fun defaultNavigation(): ViewerNavigation {
|
||||
return LNavigation()
|
||||
}
|
||||
|
||||
override fun updateNavigation(navigationMode: Int) {
|
||||
this.navigator = when (navigationMode) {
|
||||
0 -> defaultNavigation()
|
||||
1 -> LNavigation()
|
||||
2 -> KindlishNavigation()
|
||||
3 -> EdgeNavigation()
|
||||
4 -> RightAndLeftNavigation()
|
||||
5 -> DisabledNavigation()
|
||||
else -> defaultNavigation()
|
||||
}
|
||||
navigationModeChangedListener?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
|
||||
|
||||
/**
|
||||
* Items of a webtoon viewer, where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class WebtoonItems {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
var items: List<Any> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
var currentChapter: ReaderChapter? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Updates the items with the given [chapters]. It handles setting a few pages of the
|
||||
* next/previous chapter to allow seamless transitions.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
||||
currentChapter = chapters.currChapter
|
||||
items = viewerItems(chapters, forceTransition)
|
||||
}
|
||||
}
|
||||
+64
-15
@@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
@@ -8,12 +9,13 @@ import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updateMargins
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import eu.kanade.presentation.util.formattedMessage
|
||||
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.processStripImage
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -24,8 +26,11 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* Holder of the webtoon reader for a single page of a chapter.
|
||||
@@ -53,7 +58,7 @@ class WebtoonPageHolder(
|
||||
/**
|
||||
* Error layout to show when the image fails to load.
|
||||
*/
|
||||
private var errorLayout: ReaderPageErrorView? = null
|
||||
private var errorLayout: ReaderErrorBinding? = null
|
||||
|
||||
/**
|
||||
* Getter to retrieve the height of the recycler view.
|
||||
@@ -86,7 +91,6 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
fun bind(page: ReaderPage) {
|
||||
this.page = page
|
||||
frame.pageColorFilter = viewer.pageColorFilter
|
||||
loadJob?.cancel()
|
||||
loadJob = scope.launch { loadPageAndProcessStatus() }
|
||||
refreshLayoutParams()
|
||||
@@ -186,7 +190,7 @@ class WebtoonPageHolder(
|
||||
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
|
||||
// Splitting and merging decode the image, which is CPU work
|
||||
val (source, isAnimated) = withContext(Dispatchers.Default) {
|
||||
val source = viewer.simpleLongStrip.page.processStripImage(buffer)
|
||||
val source = process(buffer)
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||
Pair(source, isAnimated)
|
||||
}
|
||||
@@ -195,9 +199,9 @@ class WebtoonPageHolder(
|
||||
source,
|
||||
isAnimated,
|
||||
ReaderPageImageView.Config(
|
||||
zoomDuration = viewer.general.doubleTapAnimDuration,
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH,
|
||||
cropBorders = viewer.longStrip.cropBorders,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
),
|
||||
)
|
||||
removeErrorLayout()
|
||||
@@ -210,6 +214,32 @@ class WebtoonPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (viewer.config.dualPageSplit) {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (isDoublePage) {
|
||||
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
|
||||
return ImageUtil.splitAndMerge(imageSource, upperSide)
|
||||
}
|
||||
}
|
||||
|
||||
return imageSource
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page has an error.
|
||||
*/
|
||||
@@ -243,15 +273,34 @@ class WebtoonPageHolder(
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the error of the page.
|
||||
* Initializes a button to retry pages.
|
||||
*/
|
||||
private fun initErrorLayout(error: Throwable?) {
|
||||
val page = page ?: return
|
||||
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
|
||||
frame.addView(it, FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt()))
|
||||
errorLayout = it
|
||||
private fun initErrorLayout(error: Throwable?): ReaderErrorBinding {
|
||||
if (errorLayout == null) {
|
||||
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), frame, true)
|
||||
errorLayout?.root?.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt())
|
||||
errorLayout?.actionRetry?.setOnClickListener {
|
||||
page?.let { it.chapter.pageLoader?.retryPage(it) }
|
||||
}
|
||||
}
|
||||
errorLayout.bind(page = page, error = error, sourceId = viewer.activity.viewModel.manga?.source)
|
||||
|
||||
val imageUrl = page?.imageUrl
|
||||
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
|
||||
if (imageUrl != null) {
|
||||
if (imageUrl.startsWith("http", true)) {
|
||||
errorLayout?.actionOpenInWebView?.setOnClickListener {
|
||||
val sourceId = viewer.activity.viewModel.manga?.source
|
||||
|
||||
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
|
||||
?: context.stringResource(MR.strings.decode_image_error)
|
||||
|
||||
return errorLayout!!
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +308,7 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
private fun removeErrorLayout() {
|
||||
errorLayout?.let {
|
||||
frame.removeView(it)
|
||||
frame.removeView(it.root)
|
||||
errorLayout = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The item a webtoon viewer of [items] is on, shared by the view and Compose viewers. It tells [activity] of each new
|
||||
* page and preloads the chapters around it. A scroll further than [hideThreshold] hides the menu.
|
||||
*/
|
||||
class WebtoonPageTracker(
|
||||
private val activity: ReaderActivity,
|
||||
private val items: WebtoonItems,
|
||||
private val hideThreshold: Int,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
var currentPage: Any? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Marks the item at [position], the last one whose end is visible, as active.
|
||||
*/
|
||||
fun onScrolled(position: Int) {
|
||||
val item = items.items.getOrNull(position)
|
||||
val allowPreload = checkAllowPreload(item as? ReaderPage)
|
||||
if (item != null && currentPage != item) {
|
||||
currentPage = item
|
||||
when (item) {
|
||||
is ReaderPage -> onPageSelected(item, allowPreload)
|
||||
is ChapterTransition -> onTransitionSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the list scrolled by [dy] pixels, leaving the items from [firstVisible] to [lastEndVisible] on
|
||||
* screen.
|
||||
*/
|
||||
fun onScrolledBy(dy: Float, firstVisible: Int, lastEndVisible: Int) {
|
||||
if (abs(dy) > hideThreshold && activity.viewModel.state.value.menuVisible) {
|
||||
activity.hideMenu()
|
||||
}
|
||||
|
||||
if (dy < 0) {
|
||||
val firstItem = items.items.getOrNull(firstVisible)
|
||||
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
|
||||
activity.requestPreloadChapter(firstItem.to)
|
||||
}
|
||||
}
|
||||
|
||||
val lastItem = items.items.getOrNull(lastEndVisible)
|
||||
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
|
||||
activity.showMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAllowPreload(page: ReaderPage?): Boolean {
|
||||
// Page is transition page - preload allowed
|
||||
page ?: return true
|
||||
|
||||
// Initial opening - preload allowed
|
||||
currentPage ?: return true
|
||||
|
||||
// Allow preload for
|
||||
// 1. Going between pages of same chapter
|
||||
// 2. Next chapter page
|
||||
return when (page.chapter) {
|
||||
(currentPage as? ReaderPage)?.chapter -> true
|
||||
lastChapter() -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [page] is marked as active. It notifies the activity of the change and requests the preload of
|
||||
* the next chapter if this is the last page.
|
||||
*/
|
||||
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
|
||||
val pages = page.chapter.pages ?: return
|
||||
logcat { "onPageSelected: ${page.number}/${pages.size}" }
|
||||
activity.onPageSelected(page)
|
||||
|
||||
// Preload next chapter once we're within the last 5 pages of the current chapter
|
||||
val inPreloadRange = pages.size - page.number < 5
|
||||
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
|
||||
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
|
||||
val transitionChapter = lastChapter()
|
||||
if (transitionChapter != null) {
|
||||
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
|
||||
activity.requestPreloadChapter(transitionChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a [transition] is marked as active. It request the preload of the destination chapter of the
|
||||
* transition.
|
||||
*/
|
||||
private fun onTransitionSelected(transition: ChapterTransition) {
|
||||
logcat { "onTransitionSelected: $transition" }
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
logcat { "Request preload destination chapter because we're on the transition" }
|
||||
activity.requestPreloadChapter(toChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chapter at the end of the list: the next transition's, or that of the next chapter's pages.
|
||||
*/
|
||||
private fun lastChapter() = when (val lastItem = items.items.lastOrNull()) {
|
||||
is ChapterTransition.Next -> lastItem.to
|
||||
is ReaderPage -> lastItem.chapter
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
-9
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.MotionEvent
|
||||
@@ -23,14 +22,6 @@ class WebtoonStripImageView(context: Context) : View(context) {
|
||||
private var imageHeight = 0
|
||||
|
||||
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
|
||||
/** Applied to the strips as they draw. */
|
||||
var colorFilter: ColorFilter?
|
||||
get() = paint.colorFilter
|
||||
set(value) {
|
||||
paint.colorFilter = value
|
||||
invalidate()
|
||||
}
|
||||
private val clipRect = Rect()
|
||||
private val dstRect = Rect()
|
||||
|
||||
|
||||
+106
-12
@@ -4,12 +4,21 @@ import android.view.Gravity
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.appcompat.widget.AppCompatButton
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.view.isNotEmpty
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
|
||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
/**
|
||||
* Holder of the webtoon viewer that contains a chapter transition.
|
||||
@@ -19,8 +28,20 @@ class WebtoonTransitionHolder(
|
||||
viewer: WebtoonViewer,
|
||||
) : WebtoonBaseHolder(layout, viewer) {
|
||||
|
||||
private val scope = MainScope()
|
||||
private var stateJob: Job? = null
|
||||
|
||||
private val transitionView = ReaderTransitionView(context)
|
||||
|
||||
/**
|
||||
* View container of the current status of the transition page. Child views will be added
|
||||
* dynamically.
|
||||
*/
|
||||
private var pagesContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
init {
|
||||
layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
layout.orientation = LinearLayout.VERTICAL
|
||||
@@ -30,22 +51,95 @@ class WebtoonTransitionHolder(
|
||||
val paddingHorizontal = 32.dpToPx
|
||||
layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
|
||||
|
||||
val childMargins = 16.dpToPx
|
||||
val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
|
||||
setMargins(0, childMargins, 0, childMargins)
|
||||
}
|
||||
|
||||
layout.addView(transitionView)
|
||||
layout.addView(pagesContainer, childParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the given [transition] with this view holder.
|
||||
* Binds the given [transition] with this view holder, subscribing to its state.
|
||||
*/
|
||||
fun bind(transition: ChapterTransition) {
|
||||
transitionView.bind(
|
||||
transition = transition,
|
||||
downloadState = viewer.downloadManager.transitionDownloadState(
|
||||
transition,
|
||||
viewer.activity.viewModel.manga,
|
||||
viewer.activity.viewModel.source,
|
||||
),
|
||||
onRetry = viewer.activity::requestPreloadChapter,
|
||||
statusPadding = PaddingValues(vertical = 16.dp),
|
||||
transition,
|
||||
viewer.downloadManager,
|
||||
viewer.activity.viewModel.manga,
|
||||
viewer.activity.viewModel.source,
|
||||
)
|
||||
|
||||
transition.to?.let { observeStatus(it, transition) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is recycled and being added to the view pool.
|
||||
*/
|
||||
override fun recycle() {
|
||||
stateJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
|
||||
* state, the pages container is cleaned up before setting the new state.
|
||||
*/
|
||||
private fun observeStatus(chapter: ReaderChapter, transition: ChapterTransition) {
|
||||
stateJob?.cancel()
|
||||
stateJob = scope.launch {
|
||||
chapter.stateFlow
|
||||
.collectLatest { state ->
|
||||
pagesContainer.removeAllViews()
|
||||
when (state) {
|
||||
is ReaderChapter.State.Loading -> setLoading()
|
||||
is ReaderChapter.State.Error -> setError(state.error, transition)
|
||||
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
|
||||
// No additional view is added
|
||||
}
|
||||
}
|
||||
pagesContainer.isVisible = pagesContainer.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the loading state on the pages container.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
val progress = CircularProgressIndicator(context)
|
||||
progress.isIndeterminate = true
|
||||
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.transition_pages_loading)
|
||||
}
|
||||
|
||||
pagesContainer.addView(progress)
|
||||
pagesContainer.addView(textView)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error state on the pages container.
|
||||
*/
|
||||
private fun setError(error: Throwable, transition: ChapterTransition) {
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
|
||||
}
|
||||
|
||||
val retryBtn = AppCompatButton(context).apply {
|
||||
wrapContent()
|
||||
text = context.stringResource(MR.strings.action_retry)
|
||||
setOnClickListener {
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
viewer.activity.requestPreloadChapter(toChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pagesContainer.addView(textView)
|
||||
pagesContainer.addView(retryBtn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PointF
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asAndroidColorFilter
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
@@ -19,18 +15,12 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.GeneralSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.LocalPageColorFilter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.LongStripSettings
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.collectChanges
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import mihon.app.di.appGraph
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -65,41 +55,25 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
*/
|
||||
private val layoutManager = WebtoonLayoutManager(activity, scrollDistance)
|
||||
|
||||
private val settingsViewModel = activity.settingsViewModel
|
||||
|
||||
/**
|
||||
* Settings every long strip viewer uses, like crop image borders.
|
||||
* Configuration used by this viewer, like allow taps, or crop image borders.
|
||||
*/
|
||||
val longStrip: LongStripSettings
|
||||
get() = settingsViewModel.longStrip.value
|
||||
|
||||
/**
|
||||
* Settings only the webtoon viewers use, like side padding or tap zones.
|
||||
*/
|
||||
val simpleLongStrip: SimpleLongStripSettings
|
||||
get() = settingsViewModel.simpleLongStrip.value
|
||||
|
||||
/**
|
||||
* Settings shared by every viewer, like allow taps or page transitions.
|
||||
*/
|
||||
val general: GeneralSettings
|
||||
get() = settingsViewModel.general.value
|
||||
|
||||
private val webtoonItems = WebtoonItems()
|
||||
val config = WebtoonConfig(scope, readerPreferences)
|
||||
|
||||
/**
|
||||
* Adapter of the recycler view.
|
||||
*/
|
||||
private val adapter = WebtoonAdapter(this, webtoonItems)
|
||||
private val adapter = WebtoonAdapter(this)
|
||||
|
||||
private val tracker = WebtoonPageTracker(
|
||||
activity,
|
||||
webtoonItems,
|
||||
readerPreferences.readerHideThreshold.get().threshold,
|
||||
)
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
private var currentPage: Any? = null
|
||||
|
||||
private val threshold: Int by lazy { readerPreferences.readerHideThreshold.get().threshold }
|
||||
|
||||
init {
|
||||
recycler.sidePaddingPercent = simpleLongStrip.page.sidePadding
|
||||
recycler.sidePaddingPercent = config.sidePadding
|
||||
recycler.setItemViewCacheSize(RECYCLER_VIEW_CACHE_SIZE)
|
||||
recycler.isVisible = false // Don't let the recycler layout yet
|
||||
recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
@@ -111,11 +85,24 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
onScrolled()
|
||||
tracker.onScrolledBy(
|
||||
dy = dy.toFloat(),
|
||||
firstVisible = layoutManager.findFirstVisibleItemPosition(),
|
||||
lastEndVisible = layoutManager.findLastEndVisibleItemPosition(),
|
||||
)
|
||||
|
||||
if ((dy > threshold || dy < -threshold) && activity.viewModel.state.value.menuVisible) {
|
||||
activity.hideMenu()
|
||||
}
|
||||
|
||||
if (dy < 0) {
|
||||
val firstIndex = layoutManager.findFirstVisibleItemPosition()
|
||||
val firstItem = adapter.items.getOrNull(firstIndex)
|
||||
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
|
||||
activity.requestPreloadChapter(firstItem.to)
|
||||
}
|
||||
}
|
||||
|
||||
val lastIndex = layoutManager.findLastEndVisibleItemPosition()
|
||||
val lastItem = adapter.items.getOrNull(lastIndex)
|
||||
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
|
||||
activity.showMenu()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -124,14 +111,18 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
recycler.getLocationOnScreen(viewPosition)
|
||||
val viewPositionRelativeToWindow = IntArray(2)
|
||||
recycler.getLocationInWindow(viewPositionRelativeToWindow)
|
||||
val pos = Offset(
|
||||
val pos = PointF(
|
||||
(event.rawX - viewPosition[0] + viewPositionRelativeToWindow[0]) / recycler.width,
|
||||
(event.rawY - viewPosition[1] + viewPositionRelativeToWindow[1]) / recycler.originalHeight,
|
||||
)
|
||||
activity.onViewerTap(pos)
|
||||
when (config.navigator.getAction(pos)) {
|
||||
NavigationRegion.MENU -> activity.toggleMenu()
|
||||
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
|
||||
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
|
||||
}
|
||||
}
|
||||
recycler.longTapListener = f@{ event ->
|
||||
if (activity.viewModel.state.value.menuVisible || general.longTapEnabled) {
|
||||
if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) {
|
||||
val child = recycler.findChildViewUnder(event.x, event.y)
|
||||
if (child != null) {
|
||||
val position = recycler.getChildAdapterPosition(child)
|
||||
@@ -145,54 +136,57 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
false
|
||||
}
|
||||
|
||||
settingsViewModel.general.collectChanges(scope) { old, new ->
|
||||
if (old != null && old.theme != new.theme) {
|
||||
ActivityCompat.recreate(activity)
|
||||
}
|
||||
config.imagePropertyChangedListener = {
|
||||
recycler.sidePaddingPercent = config.sidePadding
|
||||
refreshAdapter()
|
||||
}
|
||||
settingsViewModel.longStrip.collectChanges(scope) { old, new ->
|
||||
if (old != null && old.cropBorders != new.cropBorders) {
|
||||
refreshAdapter()
|
||||
}
|
||||
if (old?.zoomOutDisabled != new.zoomOutDisabled) {
|
||||
frame.zoomOutDisabled = new.zoomOutDisabled
|
||||
}
|
||||
|
||||
config.themeChangedListener = {
|
||||
ActivityCompat.recreate(activity)
|
||||
}
|
||||
settingsViewModel.simpleLongStrip.collectChanges(scope) { old, new ->
|
||||
if (old != null && old.page != new.page) {
|
||||
recycler.sidePaddingPercent = new.page.sidePadding
|
||||
refreshAdapter()
|
||||
}
|
||||
if (old?.doubleTapZoom != new.doubleTapZoom) {
|
||||
frame.doubleTapZoom = new.doubleTapZoom
|
||||
}
|
||||
|
||||
config.doubleTapZoomChangedListener = {
|
||||
frame.doubleTapZoom = it
|
||||
}
|
||||
|
||||
config.zoomPropertyChangedListener = {
|
||||
frame.zoomOutDisabled = it
|
||||
}
|
||||
|
||||
config.navigationModeChangedListener = {
|
||||
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
|
||||
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
|
||||
}
|
||||
|
||||
frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
frame.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
frame.addView(recycler)
|
||||
}
|
||||
|
||||
/** The page images' colours, for pages bound later - see [LocalPageColorFilter]. */
|
||||
var pageColorFilter: ColorFilter? = null
|
||||
private set
|
||||
private fun checkAllowPreload(page: ReaderPage?): Boolean {
|
||||
// Page is transition page - preload allowed
|
||||
page ?: return true
|
||||
|
||||
/** Every page view made, as the recycler's cached ones are neither children nor always bound again. */
|
||||
private val pageViews = Collections.newSetFromMap(WeakHashMap<ReaderPageImageView, Boolean>())
|
||||
// Initial opening - preload allowed
|
||||
currentPage ?: return true
|
||||
|
||||
fun onPageViewCreated(view: ReaderPageImageView) {
|
||||
pageViews += view
|
||||
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
|
||||
val nextChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as? ReaderPage)?.chapter
|
||||
|
||||
// Allow preload for
|
||||
// 1. Going between pages of same chapter
|
||||
// 2. Next chapter page
|
||||
return when (page.chapter) {
|
||||
(currentPage as? ReaderPage)?.chapter -> true
|
||||
nextChapter -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val filter = LocalPageColorFilter.current
|
||||
val pageFilter = remember(filter) { filter?.asAndroidColorFilter() }
|
||||
SideEffect {
|
||||
pageColorFilter = pageFilter
|
||||
pageViews.forEach { it.pageColorFilter = pageFilter }
|
||||
}
|
||||
AndroidView(factory = { frame }, modifier = modifier)
|
||||
/**
|
||||
* Returns the view this viewer uses.
|
||||
*/
|
||||
override fun getView(): View {
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,11 +197,46 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the RecyclerView listener when a [page] is marked as active. It notifies the
|
||||
* activity of the change and requests the preload of the next chapter if this is the last page.
|
||||
*/
|
||||
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
|
||||
val pages = page.chapter.pages ?: return
|
||||
logcat { "onPageSelected: ${page.number}/${pages.size}" }
|
||||
activity.onPageSelected(page)
|
||||
|
||||
// Preload next chapter once we're within the last 5 pages of the current chapter
|
||||
val inPreloadRange = pages.size - page.number < 5
|
||||
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
|
||||
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
|
||||
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
|
||||
val transitionChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as?ReaderPage)?.chapter
|
||||
if (transitionChapter != null) {
|
||||
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
|
||||
activity.requestPreloadChapter(transitionChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the RecyclerView listener when a [transition] is marked as active. It request the
|
||||
* preload of the destination chapter of the transition.
|
||||
*/
|
||||
private fun onTransitionSelected(transition: ChapterTransition) {
|
||||
logcat { "onTransitionSelected: $transition" }
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
logcat { "Request preload destination chapter because we're on the transition" }
|
||||
activity.requestPreloadChapter(toChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells this viewer to set the given [chapters] as active.
|
||||
*/
|
||||
override fun setChapters(chapters: ViewerChapters) {
|
||||
val forceTransition = general.alwaysShowChapterTransition || tracker.currentPage is ChapterTransition
|
||||
val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
|
||||
adapter.setChapters(chapters, forceTransition)
|
||||
|
||||
if (recycler.isGone) {
|
||||
@@ -233,15 +262,24 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScrolled(pos: Int? = null) {
|
||||
tracker.onScrolled(pos ?: layoutManager.findLastEndVisibleItemPosition())
|
||||
fun onScrolled(pos: Int? = null) {
|
||||
val position = pos ?: layoutManager.findLastEndVisibleItemPosition()
|
||||
val item = adapter.items.getOrNull(position)
|
||||
val allowPreload = checkAllowPreload(item as? ReaderPage)
|
||||
if (item != null && currentPage != item) {
|
||||
currentPage = item
|
||||
when (item) {
|
||||
is ReaderPage -> onPageSelected(item, allowPreload)
|
||||
is ChapterTransition -> onTransitionSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls up by [scrollDistance].
|
||||
*/
|
||||
override fun moveBackward() {
|
||||
if (general.usePageTransitions) {
|
||||
private fun scrollUp() {
|
||||
if (config.usePageTransitions) {
|
||||
recycler.smoothScrollBy(0, -scrollDistance)
|
||||
} else {
|
||||
recycler.scrollBy(0, -scrollDistance)
|
||||
@@ -251,14 +289,60 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
/**
|
||||
* Scrolls down by [scrollDistance].
|
||||
*/
|
||||
override fun moveForward() {
|
||||
if (general.usePageTransitions) {
|
||||
private fun scrollDown() {
|
||||
if (config.usePageTransitions) {
|
||||
recycler.smoothScrollBy(0, scrollDistance)
|
||||
} else {
|
||||
recycler.scrollBy(0, scrollDistance)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a key [event] is received. It should return true
|
||||
* if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
val isUp = event.action == KeyEvent.ACTION_UP
|
||||
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) scrollDown() else scrollUp()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) scrollUp() else scrollDown()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
KeyEvent.KEYCODE_PAGE_UP,
|
||||
-> if (isUp) scrollUp()
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_PAGE_DOWN,
|
||||
-> if (isUp) scrollDown()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a generic motion [event] is received. It should
|
||||
* return true if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies adapter of changes around the current page to trigger a relayout in the recycler.
|
||||
* Used when an image configuration is changed.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import android.graphics.RectF
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
|
||||
fun RectF.invert(invertMode: ReaderPreferences.TappingInvertMode): RectF {
|
||||
val horizontal = invertMode.shouldInvertHorizontal
|
||||
val vertical = invertMode.shouldInvertVertical
|
||||
return when {
|
||||
horizontal && vertical -> RectF(1f - this.right, 1f - this.bottom, 1f - this.left, 1f - this.top)
|
||||
vertical -> RectF(this.left, 1f - this.bottom, this.right, 1f - this.top)
|
||||
horizontal -> RectF(1f - this.right, this.top, 1f - this.left, this.bottom)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import com.hippo.unifile.UniFile
|
||||
import eu.kanade.domain.ui.model.ThemeMode
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.lang.truncateCenter
|
||||
import logcat.LogPriority
|
||||
import mihon.app.di.appGraph
|
||||
@@ -101,15 +100,15 @@ fun Context.createFileInCacheDir(name: String): File {
|
||||
private val ReaderGrayBackgroundColor = Color.rgb(0x20, 0x21, 0x25)
|
||||
|
||||
/**
|
||||
* Background color of the reader for the given [readerTheme].
|
||||
* Background color of the reader for the given [readerTheme] preference value.
|
||||
*
|
||||
* Automatic resolves against the current night mode, matching the activity background.
|
||||
* Automatic (3) resolves against the current night mode, matching the activity background.
|
||||
*/
|
||||
fun Context.readerBackgroundColor(readerTheme: ReaderPreferences.ReaderTheme): Int = when (readerTheme) {
|
||||
ReaderPreferences.ReaderTheme.WHITE -> Color.WHITE
|
||||
ReaderPreferences.ReaderTheme.BLACK -> Color.BLACK
|
||||
ReaderPreferences.ReaderTheme.GRAY -> ReaderGrayBackgroundColor
|
||||
ReaderPreferences.ReaderTheme.AUTOMATIC -> if (isNightMode()) ReaderGrayBackgroundColor else Color.WHITE
|
||||
fun Context.readerBackgroundColor(readerTheme: Int): Int = when (readerTheme) {
|
||||
0 -> Color.WHITE // White
|
||||
2 -> ReaderGrayBackgroundColor // Gray
|
||||
3 -> if (isNightMode()) ReaderGrayBackgroundColor else Color.WHITE // Automatic
|
||||
else -> Color.BLACK // Black
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,14 +122,13 @@ fun Context.createReaderThemeContext(): Context {
|
||||
val preferences = graph.uiPreferences
|
||||
val readerPreferences = graph.readerPreferences
|
||||
val themeMode = preferences.themeMode.get()
|
||||
val isDarkBackground = when (ReaderPreferences.ReaderTheme.fromValue(readerPreferences.readerTheme.get())) {
|
||||
ReaderPreferences.ReaderTheme.BLACK, ReaderPreferences.ReaderTheme.GRAY -> true
|
||||
// Automatic bg uses activity background by default
|
||||
ReaderPreferences.ReaderTheme.AUTOMATIC -> when (themeMode) {
|
||||
val isDarkBackground = when (readerPreferences.readerTheme.get()) {
|
||||
1, 2 -> true // Black, Gray
|
||||
3 -> when (themeMode) { // Automatic bg uses activity background by default
|
||||
ThemeMode.SYSTEM -> applicationContext.isNightMode()
|
||||
else -> themeMode == ThemeMode.DARK
|
||||
}
|
||||
ReaderPreferences.ReaderTheme.WHITE -> false
|
||||
else -> false // White
|
||||
}
|
||||
val expected = if (isDarkBackground) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
if (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK != expected) {
|
||||
|
||||
@@ -24,3 +24,19 @@ fun Context.activeNetworkState(): NetworkState {
|
||||
isWifi = wifiManager.isWifiEnabled && capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.networkStateFlow() = callbackFlow {
|
||||
val networkCallback = object : NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
trySend(activeNetworkState())
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(activeNetworkState())
|
||||
}
|
||||
}
|
||||
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package androidx.preference
|
||||
|
||||
/**
|
||||
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
|
||||
*/
|
||||
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
|
||||
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
|
||||
return onBindEditTextListener
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package androidx.preference
|
||||
|
||||
/**
|
||||
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
|
||||
*/
|
||||
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
|
||||
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
|
||||
return onBindEditTextListener
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [onChange] whenever this preference's state, visibility or children change, the package-private hook
|
||||
* [PreferenceGroupAdapter] uses to rebind.
|
||||
*/
|
||||
fun Preference.setOnStateChangeListener(onChange: (Preference) -> Unit) {
|
||||
setOnPreferenceChangeInternalListener(
|
||||
object : Preference.OnPreferenceChangeInternalListener {
|
||||
override fun onPreferenceChange(preference: Preference) = onChange(preference)
|
||||
override fun onPreferenceHierarchyChange(preference: Preference) = onChange(preference)
|
||||
override fun onPreferenceVisibilityChange(preference: Preference) = onChange(preference)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package eu.kanade.tachiyomi.util.view
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
@@ -16,13 +15,8 @@ import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.presentation.theme.TachiyomiTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import mihon.app.di.appGraph
|
||||
|
||||
inline fun ComponentActivity.setComposeContent(
|
||||
@@ -71,19 +65,3 @@ fun View?.isVisibleOnScreen(): Boolean {
|
||||
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
|
||||
return actualPosition.intersect(screen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] in imeOptions while [BasePreferences.incognitoMode] is
|
||||
* true. Some IMEs may not respect this flag.
|
||||
*/
|
||||
fun EditText.setIncognito(basePreferences: BasePreferences, viewScope: CoroutineScope) {
|
||||
basePreferences.incognitoMode.changes()
|
||||
.onEach {
|
||||
imeOptions = if (it) {
|
||||
imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
|
||||
} else {
|
||||
imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
|
||||
}
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.R
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import mihon.app.di.appGraph
|
||||
|
||||
/**
|
||||
* A custom [TextInputEditText] that sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] to imeOptions
|
||||
* if [BasePreferences.incognitoMode] is true. Some IMEs may not respect this flag.
|
||||
*
|
||||
* @see setIncognito
|
||||
*/
|
||||
class TachiyomiTextInputEditText @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.editTextStyle,
|
||||
) : TextInputEditText(context, attrs, defStyleAttr) {
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
val basePreferences = context.appGraph.basePreferences
|
||||
setIncognito(basePreferences, scope!!)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sets Flow to this [EditText] that sets [EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING] to imeOptions
|
||||
* if [BasePreferences.incognitoMode] is true. Some IMEs may not respect this flag.
|
||||
*/
|
||||
fun EditText.setIncognito(basePreferences: BasePreferences, viewScope: CoroutineScope) {
|
||||
basePreferences.incognitoMode.changes()
|
||||
.onEach {
|
||||
imeOptions = if (it) {
|
||||
imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
|
||||
} else {
|
||||
imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
|
||||
}
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.core.security.PrivacyPreferences
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateWorker
|
||||
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreWorker
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
@@ -42,7 +43,6 @@ import eu.kanade.tachiyomi.util.CrashLogUtil
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import mihon.core.metro.IsDebugBuild
|
||||
import mihon.data.cache.ChapterCache
|
||||
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
package mihon.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
import mihon.core.common.cache.TtlDiskCache
|
||||
import mihon.core.metro.AppCoroutineScope
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* The page lists and images of chapters read online.
|
||||
*
|
||||
* Page lists expire soon, as sources sign or rotate their image URLs. Images last longer.
|
||||
*/
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
class ChapterCache(
|
||||
private val context: Context,
|
||||
private val json: Json,
|
||||
@AppCoroutineScope scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val root = File(context.cacheDir, "chapter_cache")
|
||||
|
||||
// Out of the cache directory, so the system clearing that leaves the indexes
|
||||
private val indexes = File(context.noBackupFilesDir, "chapter_cache")
|
||||
|
||||
private val pageLists = TtlDiskCache(File(root, "pages"), File(indexes, "pages.index"), 1.hours, scope)
|
||||
|
||||
private val images = TtlDiskCache(File(root, "images"), File(indexes, "images.index"), 1.days, scope)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
pageLists.warmUp()
|
||||
images.warmUp()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readableSize(): String = Formatter.formatFileSize(context, pageLists.totalSize() + images.totalSize())
|
||||
|
||||
suspend fun getPageList(chapter: Chapter): List<Page>? {
|
||||
val input = pageLists.open(chapter.key) ?: return null
|
||||
return try {
|
||||
json.decodeFromString(withContext(Dispatchers.IO) { input.bufferedReader().use { it.readText() } })
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logcat(LogPriority.WARN, e) { "Failed to read page list from cache" }
|
||||
pageLists.remove(chapter.key)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun putPageList(chapter: Chapter, pages: List<Page>) {
|
||||
try {
|
||||
val value = json.encodeToString(pages).toByteArray()
|
||||
pageLists.put(chapter.key) { it.write(value) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isImageInCache(imageUrl: String): Boolean = imageUrl in images
|
||||
|
||||
/**
|
||||
* The image's file, or null when it isn't cached.
|
||||
*/
|
||||
suspend fun getImageFile(imageUrl: String): File? = images.get(imageUrl)
|
||||
|
||||
/**
|
||||
* Opens the image, or returns null when it isn't cached.
|
||||
*/
|
||||
suspend fun openImage(imageUrl: String): InputStream? = images.open(imageUrl)
|
||||
|
||||
suspend fun removeImage(imageUrl: String) {
|
||||
images.remove(imageUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the image [response] holds, and closes it.
|
||||
*
|
||||
* @param live also receives the bytes as they arrive.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun putImage(imageUrl: String, response: Response, live: DownloadStream? = null): File {
|
||||
response.body.use { body ->
|
||||
live?.reserve(body.contentLength())
|
||||
return images.put(imageUrl) { out ->
|
||||
body.source().use { source ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = source.read(buffer)
|
||||
if (n < 0) break
|
||||
out.write(buffer, 0, n)
|
||||
live?.append(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything, returning how many entries there were.
|
||||
*/
|
||||
suspend fun clear(): Int = pageLists.clear() + images.clear()
|
||||
|
||||
private val Chapter.key: String
|
||||
get() = "$mangaId$url"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/switchWidget"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="false"
|
||||
android:clickable="false"
|
||||
android:background="@null"/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/reader_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/viewer_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:descendantFocusability="blocksDescendants" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<eu.kanade.tachiyomi.ui.reader.ReaderNavigationOverlayView
|
||||
android:id="@+id/navigation_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/compose_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/error_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/decode_image_error"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||
|
||||
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
|
||||
android:id="@+id/action_retry"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/action_retry" />
|
||||
|
||||
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
|
||||
android:id="@+id/action_open_in_web_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/action_open_in_web_view"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -29,6 +29,8 @@
|
||||
<item name="android:textColor">?attr/colorOnBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.Tachiyomi.Preference.Secondary" parent="TextAppearance.Material3.BodySmall" />
|
||||
|
||||
|
||||
<!--=======================-->
|
||||
<!--Widgets.MaterialDivider-->
|
||||
@@ -49,4 +51,17 @@
|
||||
<item name="elevationOverlayEnabled">@bool/elevationOverlayEnabled</item>
|
||||
</style>
|
||||
|
||||
|
||||
<!--===========-->
|
||||
<!--Preferences-->
|
||||
<!--===========-->
|
||||
<style name="PreferenceThemeOverlay.Tachiyomi">
|
||||
<item name="preferenceCategoryTitleTextAppearance">?attr/textAppearanceBodyMedium</item>
|
||||
<item name="switchPreferenceCompatStyle">@style/PreferenceThemeOverlay.Tachiyomi.SwitchPreferenceCompat</item>
|
||||
</style>
|
||||
|
||||
<style name="PreferenceThemeOverlay.Tachiyomi.SwitchPreferenceCompat" parent="Preference.SwitchPreferenceCompat.Material">
|
||||
<item name="android:widgetLayout">@layout/pref_widget_switch_material</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user